Skip to content

Commit ad02960

Browse files
committed
add replaygain support
1 parent 878eba7 commit ad02960

10 files changed

Lines changed: 163 additions & 6 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,12 @@ For more information on the options (`[options]`) available, run `ffmpeg-normali
294294
295295
- `-p, --print-stats`: Print loudness statistics for both passes formatted as JSON to stdout.
296296
297+
- `--replaygain`: Write [ReplayGain](https://en.wikipedia.org/wiki/ReplayGain) tags to the original file without normalizing.
298+
299+
This mode will overwrite the input file and ignore other options.
300+
301+
Only works with EBU normalization, and only with .mp3, .mp4/.m4a, .ogg, .opus for now.
302+
297303
### EBU R128 Normalization
298304
299305
- `-lrt LOUDNESS_RANGE_TARGET, --loudness-range-target LOUDNESS_RANGE_TARGET`: EBU Loudness Range Target in LUFS (default: 7.0).
@@ -488,9 +494,9 @@ If you have to use an outdated ffmpeg version, you can only use `rms` or `peak`
488494
489495
### Should I use this to normalize my music collection?
490496
491-
Generally, no.
497+
You can use the `--replaygain` option to write ReplayGain tags to the original file without normalizing. This makes most music players understand the loudness difference and adjust the volume accordingly.
492498
493-
When you run `ffmpeg-normalize` and re-encode files with MP3 or AAC, you will inevitably introduce [generation loss](https://en.wikipedia.org/wiki/Generation_loss). Therefore, I do not recommend running this on your precious music collection, unless you have a backup of the originals or accept potential quality reduction. If you just want to normalize the subjective volume of the files without changing the actual content, consider using [MP3Gain](http://mp3gain.sourceforge.net/) and [aacgain](http://aacgain.altosdesign.com/).
499+
If you decide to run `ffmpeg-normalize` with the default options, it will encode the audio with PCM audio (the default), and the resulting files will be very large. You can also choose to re-encode the files with MP3 or AAC, but you will inevitably introduce [generation loss](https://en.wikipedia.org/wiki/Generation_loss). Therefore, I do not recommend running this kind of destructive operation on your precious music collection, unless you have a backup of the originals or accept potential quality reduction.
494500
495501
### Why are my output files MKV?
496502

ffmpeg_normalize/__main__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ def create_parser() -> argparse.ArgumentParser:
159159
action="store_true",
160160
help="Print loudness statistics for both passes formatted as JSON to stdout.",
161161
)
162+
group_normalization.add_argument(
163+
"--replaygain",
164+
action="store_true",
165+
help=textwrap.dedent(
166+
"""\
167+
Write ReplayGain tags to the original file without normalizing.
168+
This mode will overwrite the input file and ignore other options.
169+
Only works with EBU normalization, and only with .mp3, .mp4/.m4a, .ogg, .opus for now.
170+
"""
171+
),
172+
)
162173

163174
# group_normalization.add_argument(
164175
# '--threshold',
@@ -562,6 +573,7 @@ def _split_options(opts: str) -> list[str]:
562573
extension=cli_args.extension,
563574
dry_run=cli_args.dry_run,
564575
progress=cli_args.progress,
576+
replaygain=cli_args.replaygain,
565577
)
566578

567579
if cli_args.output and len(cli_args.input) > len(cli_args.output):
@@ -595,7 +607,11 @@ def _split_options(opts: str) -> list[str]:
595607
)
596608
os.makedirs(cli_args.output_folder, exist_ok=True)
597609

598-
if os.path.exists(output_file) and not cli_args.force:
610+
if (
611+
os.path.exists(output_file)
612+
and not cli_args.force
613+
and not cli_args.replaygain
614+
):
599615
_logger.warning(
600616
f"Output file '{output_file}' already exists, skipping. Use -f to force overwriting."
601617
)

ffmpeg_normalize/_ffmpeg_normalize.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class FFmpegNormalize:
8383
dry_run (bool, optional): Dry run. Defaults to False.
8484
debug (bool, optional): Debug. Defaults to False.
8585
progress (bool, optional): Progress. Defaults to False.
86+
replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
8687
8788
Raises:
8889
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
@@ -122,6 +123,7 @@ def __init__(
122123
dry_run: bool = False,
123124
debug: bool = False,
124125
progress: bool = False,
126+
replaygain: bool = False,
125127
):
126128
self.ffmpeg_exe = get_ffmpeg_exe()
127129
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
@@ -203,6 +205,7 @@ def __init__(
203205
self.dry_run = dry_run
204206
self.debug = debug
205207
self.progress = progress
208+
self.replaygain = replaygain
206209

207210
if (
208211
self.audio_codec is None or "pcm" in self.audio_codec
@@ -212,6 +215,12 @@ def __init__(
212215
"Please choose a suitable audio codec with the -c:a option."
213216
)
214217

218+
# replaygain only works for EBU for now
219+
if self.replaygain and self.normalization_type != "ebu":
220+
raise FFmpegNormalizeError(
221+
"ReplayGain only works for EBU normalization type for now."
222+
)
223+
215224
self.stats: list[LoudnessStatisticsWithMetadata] = []
216225
self.media_files: list[MediaFile] = []
217226
self.file_count = 0
@@ -263,8 +272,14 @@ def run_normalization(self) -> None:
263272
# raise the error so the program will exit
264273
raise e
265274

266-
_logger.info(f"Normalized file written to {media_file.output_file}")
267-
268275
if self.print_stats:
269-
json.dump(list(chain.from_iterable(media_file.get_stats() for media_file in self.media_files)), sys.stdout, indent=4)
276+
json.dump(
277+
list(
278+
chain.from_iterable(
279+
media_file.get_stats() for media_file in self.media_files
280+
)
281+
),
282+
sys.stdout,
283+
indent=4,
284+
)
270285
print()

ffmpeg_normalize/_media_file.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
from tempfile import mkdtemp
99
from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict
1010

11+
from mutagen.id3 import ID3, TXXX
12+
from mutagen.mp3 import MP3
13+
from mutagen.mp4 import MP4
14+
from mutagen.oggopus import OggOpus
15+
from mutagen.oggvorbis import OggVorbis
1116
from tqdm import tqdm
1217

1318
from ._cmd_utils import DUR_REGEX, CommandRunner
@@ -198,6 +203,11 @@ def run_normalization(self) -> None:
198203
# run the first pass to get loudness stats
199204
self._first_pass()
200205

206+
# shortcut to apply replaygain
207+
if self.ffmpeg_normalize.replaygain:
208+
self._run_replaygain()
209+
return
210+
201211
# run the second pass as a whole
202212
if self.ffmpeg_normalize.progress:
203213
with tqdm(
@@ -212,6 +222,103 @@ def run_normalization(self) -> None:
212222
for _ in self._second_pass():
213223
pass
214224

225+
_logger.info(f"Normalized file written to {self.output_file}")
226+
227+
def _run_replaygain(self) -> None:
228+
"""
229+
Run the replaygain process for this file.
230+
"""
231+
_logger.debug(f"Running replaygain for {self.input_file}")
232+
233+
# get the audio streams
234+
audio_streams = list(self.streams["audio"].values())
235+
236+
# get the loudnorm stats from the first pass
237+
loudnorm_stats = audio_streams[0].loudness_statistics["ebu_pass1"]
238+
239+
if loudnorm_stats is None:
240+
_logger.error("no loudnorm stats available in first pass stats!")
241+
return
242+
243+
# apply the replaygain tag from the first audio stream (to all audio streams)
244+
if len(audio_streams) > 1:
245+
_logger.warning(
246+
f"Your input file has {len(audio_streams)} audio streams. "
247+
"Only the first audio stream's replaygain tag will be applied. "
248+
"All audio streams will receive the same tag."
249+
)
250+
251+
target_level = self.ffmpeg_normalize.target_level
252+
input_i = loudnorm_stats["input_i"] # Integrated loudness
253+
input_tp = loudnorm_stats["input_tp"] # True peak
254+
255+
if input_i is None or input_tp is None:
256+
_logger.error("no input_i or input_tp available in first pass stats!")
257+
return
258+
259+
track_gain = -(input_i - target_level) # dB
260+
track_peak = 10 ** (input_tp / 20) # linear scale
261+
262+
_logger.debug(f"Track gain: {track_gain} dB")
263+
_logger.debug(f"Track peak: {track_peak}")
264+
265+
self._write_replaygain_tags(track_gain, track_peak)
266+
267+
def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
268+
"""
269+
Write the replaygain tags to the input file.
270+
271+
This is based on the code from bohning/usdb_syncer, licensed under the MIT license.
272+
See: https://github.com/bohning/usdb_syncer/blob/2fa638c4f487dffe9f5364f91e156ba54cb20233/src/usdb_syncer/resource_dl.py
273+
"""
274+
_logger.debug(f"Writing ReplayGain tags to {self.input_file}")
275+
276+
input_file_ext = os.path.splitext(self.input_file)[1]
277+
if input_file_ext == ".mp3":
278+
mp3 = MP3(self.input_file, ID3=ID3)
279+
if not mp3.tags:
280+
return
281+
mp3.tags.add(
282+
TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=[f"{track_gain:.2f} dB"])
283+
)
284+
mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_PEAK", text=[f"{track_peak:.6f}"]))
285+
mp3.save()
286+
elif input_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
287+
mp4 = MP4(self.input_file)
288+
if not mp4.tags:
289+
mp4.add_tags()
290+
if not mp4.tags:
291+
return
292+
mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = [
293+
f"{track_gain:.2f} dB".encode()
294+
]
295+
mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = [
296+
f"{track_peak:.6f}".encode()
297+
]
298+
mp4.save()
299+
elif input_file_ext == ".ogg":
300+
ogg = OggVorbis(self.input_file)
301+
ogg["REPLAYGAIN_TRACK_GAIN"] = [f"{track_gain:.2f} dB"]
302+
ogg["REPLAYGAIN_TRACK_PEAK"] = [f"{track_peak:.6f}"]
303+
ogg.save()
304+
elif input_file_ext == ".opus":
305+
opus = OggOpus(self.input_file)
306+
# See https://datatracker.ietf.org/doc/html/rfc7845#section-5.2.1
307+
opus["R128_TRACK_GAIN"] = [str(round(256 * track_gain))]
308+
opus.save()
309+
else:
310+
_logger.error(
311+
f"Unsupported input file extension: {input_file_ext} for writing replaygain tags."
312+
"Only .mp3, .mp4/.m4a, .ogg, .opus are supported."
313+
"If you think this should support more formats, please let me know at "
314+
"https://github.com/slhck/ffmpeg-normalize/issues"
315+
)
316+
return
317+
318+
_logger.info(
319+
f"Successfully wrote replaygain tags to input file {self.input_file}"
320+
)
321+
215322
def _can_write_output_video(self) -> bool:
216323
"""
217324
Determine whether the output file can contain video at all.

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ tqdm>=4.64.1
22
colorama>=0.4.6
33
ffmpeg-progress-yield>=0.11.0
44
colorlog==6.7.0
5+
mutagen>=1.47.0

test/test.mp3

9.53 KB
Binary file not shown.

test/test.mp4

1.19 KB
Binary file not shown.

test/test.ogg

6.22 KB
Binary file not shown.

test/test.opus

10.2 KB
Binary file not shown.

test/test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,3 +475,15 @@ def test_audio_channels(self):
475475
assert os.path.isfile("normalized/test2.wav")
476476
stream_info = _get_stream_info("normalized/test2.wav")[0]
477477
assert stream_info["channels"] == 2
478+
479+
def test_replaygain(self):
480+
for file in [
481+
"test/test.mp4",
482+
"test/test.mp3",
483+
"test/test.ogg",
484+
"test/test.opus",
485+
]:
486+
original_mtime = os.path.getmtime(file)
487+
ffmpeg_normalize_call([file, "--replaygain"])
488+
assert os.path.isfile(file)
489+
assert os.path.getmtime(file) > original_mtime

0 commit comments

Comments
 (0)