Transcoding Audio to Opus and AAC

Encode each upload twice from a lossless (or normalised) intermediate: Opus with libopus at 48–64 kbps for speech or 96–128 kbps for music in a WebM container, and AAC-LC with FFmpeg’s native aac at roughly 1.5× the Opus bitrate in an MP4 (.m4a) with -movflags +faststart; list both as <source> elements, Opus first, so every browser picks the smaller file it can play.

Users upload WAV voice notes (10 MB per minute), 320 kbps MP3s, phone M4As, OGG recordings from Android apps, and video files whose audio is all you want. Serving those as-is means inconsistent sizes, some formats that do not play in Safari, and headers that break seeking. Two well-chosen delivery encodes cover every browser and cut bytes by 80–95%. This page belongs to audio processing pipelines in media processing and delivery pipelines, and it expects the input produced by normalizing audio loudness with FFmpeg loudnorm.

When to use this approach

  • You play uploads in the browser with <audio> or a Web Audio player and want one small file per browser, not the original.
  • Content is a mix of speech (voice notes, podcasts, lectures) and music, and you want each to get an appropriate bitrate.
  • You need fast seeking and progressive playback, which depend on container layout more than on codec.

Prerequisites

  1. FFmpeg 6.x built with libopus (ffmpeg -encoders | grep libopus). The native aac encoder is always present; libfdk_aac is better at low bitrates but is not in most distributed builds for licensing reasons.
  2. Node 20+ for orchestration.
  3. A content classification — speech or music — from upload metadata (which screen it came from) or a heuristic.
  4. Storage for two outputs per asset, served with correct Content-Type (audio/webm, audio/mp4).

Codec and container, separately

Opus and AAC are codecs; WebM, Ogg and MP4 are containers. Browsers care about both. Opus is the most efficient general-purpose codec at every bitrate below about 128 kbps, especially for speech, and plays in Chrome, Firefox and Edge in WebM or Ogg, and in Safari 17+ in WebM or MP4 — older Safari versions do not play Opus at all. AAC-LC in MP4 plays everywhere, including every iOS version, making it the universal fallback.

Browser support matrix for Opus and AAC delivery Opus in WebM plays in Chrome, Firefox, Edge and Safari 17 or newer. AAC in MP4 plays in all of them plus older Safari and every iOS version. Listing Opus first with AAC as fallback covers every browser. Two encodes cover every browser format Chrome / Edge Firefox Safari 17+ older Safari/iOS Opus in WebM yes yes yes no Opus in Ogg yes yes partial no AAC-LC in MP4 yes yes yes yes Ship Opus/WebM for size and AAC/MP4 for reach; the browser takes the first <source> it can play. Skip Ogg for web delivery — WebM gives the same Opus bitstream with broader support.
Opus in WebM is the efficient default; AAC in MP4 is the fallback that makes the pair universal.

Implementation

import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";

export type Content = "speech" | "music";

interface Profile { opusKbps: number; aacKbps: number; channels: 1 | 2; application: "voip" | "audio" }

// Bitrates chosen per content type, not per upload bitrate.
const PROFILES: Record<Content, Profile> = {
  speech: { opusKbps: 48, aacKbps: 64, channels: 1, application: "voip" },
  music: { opusKbps: 112, aacKbps: 160, channels: 2, application: "audio" },
};

function ffmpeg(args: string[]): Promise<void> {
  return new Promise((resolve, reject) => {
    const p = spawn("ffmpeg", ["-hide_banner", "-nostats", "-y", ...args], { stdio: ["ignore", "ignore", "pipe"] });
    let tail = "";
    p.stderr.on("data", (d: Buffer) => { tail = (tail + d.toString()).slice(-2000); });
    p.on("error", reject);
    p.on("close", (c) => (c === 0 ? resolve() : reject(new Error(`ffmpeg ${c}: ${tail}`))));
  });
}

export async function encodeAudio(
  input: string,
  outBase: string,               // e.g. /tmp/work/9c1f → 9c1f.webm and 9c1f.m4a
  content: Content,
): Promise<{ webm: string; m4a: string; bytes: { webm: number; m4a: number } }> {
  const p = PROFILES[content];
  const webm = `${outBase}.webm`;
  const m4a = `${outBase}.m4a`;
  const common = ["-i", input, "-vn", "-map_metadata", "-1", "-ac", String(p.channels)];

  // Opus: always 48 kHz internally; VBR is the default and the right choice for files.
  await ffmpeg([
    ...common,
    "-c:a", "libopus",
    "-b:a", `${p.opusKbps}k`,
    "-vbr", "on",
    "-application", p.application,   // "voip" tunes for intelligibility on speech
    "-frame_duration", "20",
    "-ar", "48000",
    "-f", "webm",
    webm,
  ]);

  // AAC-LC in MP4 with the moov atom moved to the front for progressive playback.
  await ffmpeg([
    ...common,
    "-c:a", "aac",
    "-b:a", `${p.aacKbps}k`,
    "-ar", content === "speech" ? "44100" : "48000",
    "-movflags", "+faststart",
    "-f", "mp4",
    m4a,
  ]);

  const [a, b] = await Promise.all([stat(webm), stat(m4a)]);
  return { webm, m4a, bytes: { webm: a.size, m4a: b.size } };
}

/** The markup that lets each browser choose. Opus first: smaller when supported. */
export function audioElement(baseUrl: string, title: string): string {
  const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
  return [
    `<audio controls preload="metadata" aria-label="${esc(title)}">`,
    `  <source src="${baseUrl}.webm" type='audio/webm; codecs="opus"'>`,
    `  <source src="${baseUrl}.m4a" type='audio/mp4; codecs="mp4a.40.2"'>`,
    `</audio>`,
  ].join("\n");
}

// Usage: node --experimental-strip-types encode.ts normalised.wav ./out/9c1f speech
if (process.argv[2] && process.argv[3]) {
  const r = await encodeAudio(process.argv[2], process.argv[3], (process.argv[4] as Content) ?? "speech");
  console.log(r.bytes);
}

Line-by-line on the parameters that matter

  • Profiles by content, not by source bitrate. A 320 kbps MP3 of a voice memo still only needs 48 kbps Opus; the extra source bits encode room noise. Deciding by content type gives consistent quality and size.
  • Mono for speech. Most voice recordings are effectively mono even when stored as stereo. Downmixing halves the bits the encoder spends on redundant channels. For music, keep stereo.
  • -application voip tunes Opus for speech intelligibility, with more emphasis on the voice band. audio is the right mode for music and mixed content.
  • -map_metadata -1 drops tags from the source — including embedded cover art, recording device names and occasionally location. Write the tags you want explicitly afterwards.
  • -movflags +faststart rewrites the MP4 so the moov index is at the start. Without it the browser must fetch the end of the file before it can start playing or seek — see serving video with HTTP range requests, where the same atom decides seek behaviour for video.
  • preload="metadata" in the markup fetches only enough bytes to read duration, not the whole file, which matters on pages that list many audio items.

Bitrate versus perceived quality

Listening test scores against bitrate for Opus and AAC on speech On speech, Opus reaches a good score of about 4.2 at 32 kilobits per second and plateaus near 4.6 by 48. AAC-LC needs around 64 kilobits per second to reach the same 4.2 and approaches 4.5 at 96. Speech: mean opinion score vs bitrate (illustrative) 5.0 3.0 1.0 4.2 = "good" Opus 32–48 AAC ~64–80 16 48 80 144 kbps Opus reaches "good" speech at roughly half the AAC bitrate — which is why it goes first in the source list.
The gap is largest on speech at low bitrates, exactly where most user uploads live.

Configuration gotchas

Unknown encoder 'libopus'. Your FFmpeg build lacks libopus. Static builds from major distributions include it; slim container images and some Lambda layers do not. Fail the worker’s startup health check if ffmpeg -encoders lacks it, rather than discovering it on the first upload.

Safari shows 0:00 duration for WebM. Older Safari cannot play WebM audio and reports a zero-length file rather than an error. With a correct type attribute on each <source>, Safari skips the WebM source without fetching it; if you omit type, it downloads the WebM header first and only then falls through.

Could not find tag for codec opus in stream #0, codec not currently supported in container. You asked for Opus in an .m4a/MP4 with an older FFmpeg, or used -f ipod. Use -f webm for Opus, or FFmpeg 6+ and -f mp4 if you specifically need Opus in MP4 for Safari 17.

AAC output has a gap at the start. AAC encoders add priming samples (typically 1024–2112 at the start). MP4 stores an edit list so players skip them; raw .aac ADTS files do not. Always use the MP4 container for AAC delivery so gapless playback works.

Bytes per hour of speech

Storage for one hour of speech in each format One hour of speech takes 635 megabytes as 16-bit 44.1 kilohertz stereo WAV, 144 megabytes as 320 kilobit MP3, 29 megabytes as 64 kilobit AAC and 22 megabytes as 48 kilobit Opus. One hour of speech WAV upload 635 MB MP3 320k upload 144 MB AAC 64k 29 MB Opus 48k 22 MB Keep the original for re-encoding; serve only the two delivery files. Both together are 8% of the WAV.
Delivery encodes are a rounding error next to lossless uploads, so storing both formats costs almost nothing.

Deciding speech versus music automatically

When the upload screen does not tell you what kind of audio arrived, a cheap heuristic works for most cases. Speech has frequent short pauses, energy concentrated between roughly 300 Hz and 3.4 kHz, and a narrow loudness range; music is continuous, broadband and more dynamic. You already have two of those signals: the loudness range (input_lra) from the loudnorm measurement pass, and the peaks computed in generating waveform peaks for audio players, from which the fraction of near-silent buckets is one loop away.

A rule such as “more than 15% of 100 ms buckets below −40 dBFS and LRA under 8 LU means speech” classifies voice notes, lectures and podcasts correctly nearly all the time. Music misclassified as speech gets encoded at 48 kbps mono, which is noticeably worse, so bias the rule towards music when unsure — the cost of the opposite mistake is a few extra kilobytes per minute of speech. Store the chosen profile with the asset so a later re-encode reuses the same decision, and let users correct it where your product exposes the setting. If captions are also generated, the transcript’s words-per-minute is a near-perfect speech signal and can refine the choice for the next encode.

Verification

# Codec, channels, sample rate and bitrate of each output.
ffprobe -v error -show_entries stream=codec_name,channels,sample_rate,bit_rate \
  -of compact out/9c1f.webm out/9c1f.m4a

# faststart: the moov atom must precede mdat in the MP4.
ffprobe -v trace out/9c1f.m4a 2>&1 | grep -Eo "type:'(moov|mdat)'" | head -2
# type:'moov'
# type:'mdat'

# No leftover metadata from the upload.
ffprobe -v error -show_entries format_tags -of json out/9c1f.m4a

In the browser, load the <audio> element in Chrome and Safari; DevTools → Network should show Chrome fetching only the .webm and Safari 16 fetching only the .m4a, each with a small first range request for metadata.

Frequently Asked Questions

Should I still produce MP3?

Only if uploads are offered for download to apps that insist on MP3 (older podcast clients, some car systems). For in-browser playback, AAC and Opus are strictly better at the same bitrate. If you do produce MP3, 128 kbps CBR via libmp3lame is the widely compatible choice.

Is libfdk_aac worth rebuilding FFmpeg for?

At 96 kbps and above the native encoder is close enough that listeners will not tell. Below 64 kbps, or if you want HE-AAC for very low bitrates, libfdk_aac is noticeably better — but check its licence before distributing a build that includes it.

What about adaptive streaming for long audio?

For hour-long podcasts, a single progressive file with range requests is simpler and works well. Audio-only HLS makes sense when you already stream video with HLS, want mid-roll ad insertion, or need multiple bitrates for very poor networks; the packaging is the same as in packaging HLS with FFmpeg and fMP4 segments, with no video maps.