# Normalize Video Audio

> Fix a video whose audio is too quiet or whose dialogue is buried under loud action, then optionally install it into a Plex library. Trigger with "the audio is too quiet", "I can't hear the dialogue", "normalize the audio on X", "make this movie louder", or when a file sounds fine on headphones but not on the TV.

- Skill: `davemaynard/normalize-video-audio` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add davemaynard/normalize-video-audio`
- Raw SKILL.md: https://api.skillmd.com/api/skills/davemaynard/normalize-video-audio/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: davemaynard (https://skillmd.com/u/davemaynard)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/davemaynard/normalize-video-audio

---


# /normalize-video-audio

**Requires:** `ffmpeg` and `ffprobe` on your PATH (`brew install ffmpeg`, or your
package manager). Installing into a Plex library is optional and only needs a
writable media directory.

Make a too-quiet video watchable on a living-room TV. **Video is always
stream-copied** — picture quality is never touched, only the audio track.

## The core idea

"Too quiet" is almost never one problem. Measure three things before touching
anything, because they call for different fixes:

| Measurement | Healthy | What it means when it's off |
|---|---|---|
| **Integrated loudness** (`input_i`) | −16 LUFS | Below ~−20: genuinely quiet, needs gain |
| **Loudness range** (`input_lra`) | 6–10 LU | Above ~15: dialogue is buried far under the action — **this is usually the real complaint** |
| **True peak** (`input_tp`) | ≤ −1 dBTP | At/above 0: already clipped, so there is **no headroom to boost into** |

The trap: someone says "make it 50% louder," you apply a gain, and it distorts —
because the peaks were already at 0 dBFS. When loudness range is wide, the fix is
to *compress the range* (lift dialogue, hold peaks down), not to raise everything.

## Workflow

1. **Find the file.** If the user gave a title rather than a path, search their media
   library (`$MEDIA_LIBRARY` if set, otherwise ask where it lives). Confirm the real
   path before doing anything.
2. **Probe it** — codec, channels, bitrate:
   `ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,channels,channel_layout,bit_rate -of default=noprint_wrappers=1 IN`
3. **Run the script** — it measures, picks a chain from the measurements,
   encodes, and verifies:
   ```
   <directory containing this SKILL.md>/normalize.sh IN [OUT] [TARGET_LUFS]
   ```
   `OUT` defaults to `<name> {edition-Loud}.mp4` beside the input (Plex's edition
   naming; harmless elsewhere).
   Default target is −16 LUFS (streaming/broadcast standard). Use −14 for a TV
   with a weak amp. Each measurement pass reads the whole file — budget ~3 min
   per pass for a feature-length movie, ~10 min total.
4. **Report the before/after table** the script prints. Never claim it's fixed
   without it.
5. **Optional: install into Plex** if the file lives in a Plex library (see below).

## What the script chooses, and why

- **Range > 15 LU** → `acompressor` (short-term peaks) **+ `dynaudnorm`** (scene-to-scene
  gain riding). A compressor alone does *not* fix this — it works on a window of
  milliseconds, and the problem is that the whole quiet-conversation scene sits
  20 dB under the whole car-chase scene. Measured on The Dark Knight: compressor
  alone moved range 24.3 → 20.9; adding `dynaudnorm` got it to 9.0.
- **Range 8–15 LU** → compressor only.
- **Range < 8 LU** → gain only; the material is already even.
- **6+ channels** → dialogue-forward stereo downmix with the center channel at
  full weight. A default 5.1→stereo downmix buries dialogue, and that alone is
  often the entire problem on a TV with stereo speakers.

Then a two-pass `loudnorm` (measured values fed to the second pass) and a final
`alimiter` backstop.

## Gotchas (all of these have actually bitten)

- **AAC overshoots the limiter by ~3 dB.** Targeting `TP=-1.5` produced a decoded
  file measuring **+1.73 dBTP** — clipping. Always target `TP=-3` *and* keep the
  `alimiter`, then **re-measure the finished file**, not the filter settings.
  This is why step 4 exists.
- **Verify the decoded output, always.** The filter chain's intent and the encoded
  result are different things. Measure the file you're about to ship.
- **LRA has a relative gate** (−20 LU below ungated loudness). Passages far below
  the average are excluded from the range figure entirely, so a file can measure
  a deceptively low LRA. Trust the listening complaint over the number.
- **Channel names differ by layout.** `5.1` uses `BL`/`BR`; `5.1(side)` uses
  `SL`/`SR`. `FSL`/`FSR` exist in neither and will fail the filter. Use channel
  *indices* (`c0`…`c5`) in `pan` — the order is the same across both.
- **Check the source bitrate first.** A 64 kbps rip sounds thin no matter what you
  do. Encode the output at 256k so processing doesn't compound the loss, but say
  plainly that filtering can't recover what the rip discarded.
- **Don't raise the bitrate expecting quality** — it only avoids *adding* loss.

## Optional: installing into Plex

Skip this section if you don't use Plex. The Plex app on Google TV / Android TV
**cannot trigger a library scan** — that's server-side. Restarting the client only
refreshes its cached view.

```bash
# Token: on macOS the server stores it in its preferences; elsewhere read it from
# Plex Web (Settings > Troubleshooting > XML) or set PLEX_TOKEN yourself.
TOKEN=${PLEX_TOKEN:-$(defaults read ~/Library/Preferences/com.plexapp.plexmediaserver PlexOnlineToken)}
curl -s "http://localhost:32400/library/sections?X-Plex-Token=$TOKEN"          # find section id
curl -s "http://localhost:32400/library/sections/1/refresh?X-Plex-Token=$TOKEN" # scan
curl -s -X PUT "http://localhost:32400/library/sections/1/emptyTrash?X-Plex-Token=$TOKEN"
```
Never print the token into visible output.

**Two ways to place the file:**

- *Side by side* (default, reversible) — name it `Original Name {edition-Loud}.mp4`
  in the same folder. Plex's `{edition-Name}` syntax attaches it to the same movie
  as a second selectable version. Confirm with
  `curl -s "http://localhost:32400/library/metadata/<ratingKey>?X-Plex-Token=$TOKEN"` —
  both `file=` paths should hang off one `ratingKey`.
- *Replace* — move the original to `~/.Trash` (recoverable, not `rm`), then rename
  the new file to the original's exact name.

**Replacing resets watch state.** Swapping the media item rolls an in-progress
view into a completed one: the resume offset is lost and the movie shows as
watched. Warn before doing it. To restore:
```bash
curl -s "http://localhost:32400/:/unscrobble?identifier=com.plexapp.plugins.library&key=<ratingKey>&X-Plex-Token=$TOKEN"
curl -s "http://localhost:32400/:/progress?key=<ratingKey>&identifier=com.plexapp.plugins.library&time=<ms>&state=stopped&X-Plex-Token=$TOKEN"
```

## Reporting

Give the before/after table with real measured numbers, name which of the three
problems was actually wrong, and flag anything the process couldn't fix (a poor
source rip, lost watch state). Don't say "fixed" on the strength of the command
having exited 0.

