DEV Community

Cover image for Normalizing Thousands of Short Meme Clips with ffmpeg: Loudness, Silence Trimming and Small Files for a Browser Soundboard
Abid Ali
Abid Ali

Posted on

Normalizing Thousands of Short Meme Clips with ffmpeg: Loudness, Silence Trimming and Small Files for a Browser Soundboard

If you've ever tapped through a soundboard, you know the problem even if you've never named it. One button is a polite little "ding". The next is an air horn that makes you rip your earbuds out. A third seems broken, until it starts playing almost half a second late because the file begins with dead air.

None of that is a frontend bug. You can preload, pool Audio elements and tune the Web Audio graph all day, and a clip that starts with 400 ms of silence will still feel laggy. The fix belongs in the asset pipeline, before a single file reaches the browser.

This post walks through the batch pipeline I'd use for a library of short clips (roughly 0.3 to 15 seconds each): trim silence, normalize loudness, downmix, encode small, and strip junk metadata. It uses ffmpeg plus a small Node script to run it over a whole folder.

What "good" looks like for a sound button

Before writing any commands, write the spec. Mine looks like this:

  • Starts instantly. No leading silence above a tiny threshold.
  • Ends cleanly. Trailing silence trimmed, so a "random" or autoplay mode doesn't leave awkward gaps.
  • Consistent perceived loudness. Not identical peaks; similar loudness, which is what ears notice.
  • No clipping. A true-peak ceiling a bit below 0 dBFS.
  • Small. Mono, 44.1 kHz, a modest bitrate. Most meme clips are speech or simple effects, so stereo doubles the bytes for nothing.
  • Universal playback. MP3 is still the format that plays everywhere without fallbacks.

Step 1: Trim leading and trailing silence

ffmpeg's silenceremove filter can strip silence from the start. There's no built-in "trim the end" option that is simple to reason about, so the usual trick is to reverse the audio, trim the "start" again, and reverse back:

ffmpeg -hide_banner -i in.wav \
  -af "silenceremove=start_periods=1:start_threshold=-50dB:start_duration=0,\
areverse,\
silenceremove=start_periods=1:start_threshold=-50dB:start_duration=0,\
areverse" \
  trimmed.wav
Enter fullscreen mode Exit fullscreen mode

Two notes from experience:

  1. Don't set the threshold too high. At -30 dB you'll chop off the soft breath before someone says "bruh", and the clip sounds clipped at the front. Start around -50 dB and listen.
  2. Leave a tiny tail if the clip has reverb. If the trimmed ending sounds abrupt, add a 30–50 ms fade-out with afade instead of raising the threshold.

Step 2: Normalize loudness with two-pass loudnorm

Peak normalization (make the loudest sample hit -1 dB) doesn't make clips sound equally loud. A short, spiky snare and a dense, compressed bass drop can share the same peak and feel completely different. loudnorm implements EBU R128 loudness normalization, which targets perceived loudness in LUFS.

Run it in two passes. The first pass only measures:

ffmpeg -hide_banner -i trimmed.wav \
  -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json \
  -f null - 2>&1 | sed -n '/^{/,/^}/p'
Enter fullscreen mode Exit fullscreen mode

That prints JSON like:

{
  "input_i" : "-23.41",
  "input_tp" : "-4.02",
  "input_lra" : "3.10",
  "input_thresh" : "-33.62",
  "target_offset" : "0.37"
}
Enter fullscreen mode Exit fullscreen mode

The second pass feeds those numbers back in, with linear=true so loudnorm applies a single gain instead of dynamic processing when it can:

ffmpeg -hide_banner -i trimmed.wav \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11:\
measured_I=-23.41:measured_TP=-4.02:measured_LRA=3.10:\
measured_thresh=-33.62:offset=0.37:linear=true" \
  -ar 44100 normalized.wav
Enter fullscreen mode Exit fullscreen mode

The -ar 44100 matters: loudnorm works at a high internal sample rate, and without an explicit output rate you can end up with a 192 kHz file.

Which target? -16 LUFS integrated is a common target for web and mobile listening. For a soundboard that people play in a quiet room through earbuds, I'd rather slightly under-shoot than over-shoot. Pick one number and apply it to everything; consistency matters more than the exact value.

Step 3: The short-clip trap

Here's the catch that wastes an afternoon if nobody warns you. Integrated loudness is measured over gated blocks of audio. On a 0.4-second "pop" or a single click, the measurement can be unreliable, and loudnorm may push the gain in odd directions.

So the pipeline needs a fallback: if a clip is very short, skip loudnorm and do simple peak normalization with volumedetect + volume:

# measure the peak
ffmpeg -hide_banner -i trimmed.wav -af volumedetect -f null - 2>&1 | grep max_volume
# -> max_volume: -7.8 dB

# raise it so the peak lands at -2 dB
ffmpeg -hide_banner -i trimmed.wav -af "volume=5.8dB" -ar 44100 normalized.wav
Enter fullscreen mode Exit fullscreen mode

Short effects normalized this way won't match the long clips perfectly, but they'll be in the same neighbourhood, which is what the listener actually cares about.

Step 4: Encode small

ffmpeg -hide_banner -i normalized.wav \
  -ac 1 -ar 44100 \
  -c:a libmp3lame -b:a 96k \
  -map_metadata -1 -id3v2_version 0 \
  out.mp3
Enter fullscreen mode Exit fullscreen mode
  • -ac 1 downmixes to mono.
  • 96k mono MP3 is plenty for speech and most effects. Music-heavy clips can go to 128k.
  • -map_metadata -1 drops embedded tags. Uploaded clips often carry album art, encoder notes or someone's name, and none of that should go out with every play.

Step 5: Run it over a folder with Node

Here's a compact batch runner. It measures duration with ffprobe, picks the right normalization path, and runs a few files in parallel.

// normalize-clips.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readdir, mkdir } from "node:fs/promises";
import path from "node:path";

const run = promisify(execFile);
const IN = process.argv[2] ?? "raw";
const OUT = process.argv[3] ?? "out";
const TARGET = { I: -16, TP: -1.5, LRA: 11 };
const SHORT_CLIP_SECONDS = 1.0;
const CONCURRENCY = 4;

const TRIM =
  "silenceremove=start_periods=1:start_threshold=-50dB:start_duration=0," +
  "areverse,silenceremove=start_periods=1:start_threshold=-50dB:start_duration=0,areverse";

async function duration(file) {
  const { stdout } = await run("ffprobe", [
    "-v", "error", "-show_entries", "format=duration",
    "-of", "default=nw=1:nk=1", file,
  ]);
  return parseFloat(stdout);
}

async function measureLoudness(file) {
  const { stderr } = await run("ffmpeg", [
    "-hide_banner", "-i", file,
    "-af", `${TRIM},loudnorm=I=${TARGET.I}:TP=${TARGET.TP}:LRA=${TARGET.LRA}:print_format=json`,
    "-f", "null", "-",
  ]);
  return JSON.parse(stderr.slice(stderr.lastIndexOf("{"), stderr.lastIndexOf("}") + 1));
}

async function measurePeak(file) {
  const { stderr } = await run("ffmpeg", [
    "-hide_banner", "-i", file, "-af", `${TRIM},volumedetect`, "-f", "null", "-",
  ]);
  return parseFloat(stderr.match(/max_volume:\s*(-?[\d.]+) dB/)[1]);
}

async function processFile(name) {
  const src = path.join(IN, name);
  const dst = path.join(OUT, name.replace(/\.\w+$/, ".mp3"));
  let gain;

  if ((await duration(src)) < SHORT_CLIP_SECONDS) {
    const peak = await measurePeak(src);
    gain = `volume=${(-2 - peak).toFixed(2)}dB`;
  } else {
    const m = await measureLoudness(src);
    gain =
      `loudnorm=I=${TARGET.I}:TP=${TARGET.TP}:LRA=${TARGET.LRA}` +
      `:measured_I=${m.input_i}:measured_TP=${m.input_tp}` +
      `:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}` +
      `:offset=${m.target_offset}:linear=true`;
  }

  await run("ffmpeg", [
    "-hide_banner", "-y", "-i", src,
    "-af", `${TRIM},${gain}`,
    "-ac", "1", "-ar", "44100",
    "-c:a", "libmp3lame", "-b:a", "96k",
    "-map_metadata", "-1", "-id3v2_version", "0",
    dst,
  ]);
  console.log("ok", name);
}

await mkdir(OUT, { recursive: true });
const files = (await readdir(IN)).filter((f) => /\.(wav|mp3|m4a|ogg|flac)$/i.test(f));
for (let i = 0; i < files.length; i += CONCURRENCY) {
  await Promise.all(files.slice(i, i + CONCURRENCY).map((f) =>
    processFile(f).catch((e) => console.error("fail", f, e.message))));
}
Enter fullscreen mode Exit fullscreen mode

Run it with node normalize-clips.mjs raw out. On a few thousand short clips this finishes in minutes on a laptop, and you can re-run it whenever you change the target.

Step 6: Don't skip the listening pass

Numbers get you 90% of the way. The last 10% is ears. After a batch, I open the output in a simple grid page and tap through a random sample of 30–50 buttons in a row, in the order a real user might: something quiet, then something loud, then a long clip. Anything that still jumps out gets a manual gain tweak and a note in a small overrides.json that the script reads next time.

If you want a feel for what a consistent library sounds like, tap through a category of utility clips such as the sound effects soundboard on Meme Tap with headphones on, then try the same with a random folder of downloaded clips. The difference between "fine" and "annoying" is mostly loudness and leading silence.

Recap

  • Trim silence at both ends, using the areverse trick for the tail.
  • Use two-pass loudnorm with linear=true for normal clips.
  • Fall back to peak normalization for very short clips.
  • Encode mono 96k MP3, set the sample rate explicitly, and strip metadata.
  • Script it, then listen anyway.

Your frontend latency work only pays off when the files themselves start on time and sit at a sane volume.


Abid Ali is a full-stack web developer in Pakistan. He builds Meme Tap, a free browser soundboard with one-tap meme sound buttons.

Top comments (0)