/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
- Find the file. If the user gave a title rather than a path, search their media
library (
$MEDIA_LIBRARYif set, otherwise ask where it lives). Confirm the real path before doing anything. - 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 - 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]OUTdefaults to<name> {edition-Loud}.mp4beside 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. - Report the before/after table the script prints. Never claim it's fixed without it.
- 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; addingdynaudnormgot 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.5produced a decoded file measuring +1.73 dBTP — clipping. Always targetTP=-3and keep thealimiter, 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.1usesBL/BR;5.1(side)usesSL/SR.FSL/FSRexist in neither and will fail the filter. Use channel indices (c0…c5) inpan— 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.
# 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}.mp4in the same folder. Plex's{edition-Name}syntax attaches it to the same movie as a second selectable version. Confirm withcurl -s "http://localhost:32400/library/metadata/<ratingKey>?X-Plex-Token=$TOKEN"— bothfile=paths should hang off oneratingKey. - Replace — move the original to
~/.Trash(recoverable, notrm), 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:
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.