Skip to content

Commit c4b27c3

Browse files
committed
fix: strip ReplayGain tags after normalization, fixes #308
1 parent 4f2dc95 commit c4b27c3

7 files changed

Lines changed: 298 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ When adding, removing, or modifying CLI options, YOU MUST:
9393

9494
This ensures users have accurate documentation and working shell completions for all CLI flags.
9595

96+
**Important:** When modifying CLI descriptions (help texts), make sure to update the corresponding documentation in `docs/usage/cli-options.md` to keep them consistent, as well as the shell completions.
97+
9698
### Dependencies
9799

98100
The project uses:

completions/ffmpeg-normalize-shtab.zsh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
_shtab_ffmpeg_normalize_commands() {
77
local _commands=(
8-
8+
99
)
1010
_describe 'ffmpeg-normalize commands' _commands
1111
}
@@ -63,7 +63,7 @@ Otherwise, the range is -99 to 0.
6363
]:target_level:"
6464
{-p,--print-stats}"[Print loudness statistics for both passes formatted as JSON to stdout.]"
6565
"--replaygain[Write ReplayGain tags to the original file without normalizing.
66-
This mode will overwrite the input file and ignore other options.
66+
This mode will overwrite the input file and ignore other options. It will strip all ReplayGain tags.
6767
Only works with EBU normalization, and only with .mp3, .mp4\/.m4a, .ogg, .opus for now.
6868
]"
6969
"--batch[Preserve relative loudness between files (album mode).
@@ -231,7 +231,7 @@ _shtab_ffmpeg_normalize() {
231231
(( CURRENT += 1 ))
232232
curcontext="${curcontext%:*:*}:_shtab_ffmpeg_normalize-$line[1]:"
233233
case $line[1] in
234-
234+
235235
esac
236236
esac
237237
}

docs/advanced/faq.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ You can use the `--replaygain` option to write ReplayGain tags to the original f
3030

3131
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.
3232

33+
**Note:** When normalizing files (without the `--replaygain` option), any existing ReplayGain tags from the input file are automatically stripped from the output, as they are no longer accurate after normalization.
34+
3335
## Why are my output files MKV?
3436

3537
I chose MKV as a default output container since it handles almost every possible combination of audio, video, and subtitle codecs. If you know which audio/video codec you want, and which container is supported, use the output options to specify the encoder and output file name manually.

docs/usage/cli-options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Print loudness statistics for both passes formatted as JSON to stdout.
110110

111111
Write [ReplayGain](https://en.wikipedia.org/wiki/ReplayGain) tags to the original file without normalizing.
112112

113-
This mode will overwrite the input file and ignore other options.
113+
This mode will overwrite the input file and ignore other options. It will strip all ReplayGain tags.
114114

115115
Only works with EBU normalization, and only with .mp3, .mp4/.m4a, .ogg, .opus for now.
116116

src/ffmpeg_normalize/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def create_parser() -> argparse.ArgumentParser:
197197
help=textwrap.dedent(
198198
"""\
199199
Write ReplayGain tags to the original file without normalizing.
200-
This mode will overwrite the input file and ignore other options.
200+
This mode will overwrite the input file and ignore other options. It will strip all ReplayGain tags.
201201
Only works with EBU normalization, and only with .mp3, .mp4/.m4a, .ogg, .opus for now.
202202
"""
203203
),

src/ffmpeg_normalize/_media_file.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,10 @@ def run_normalization(self, batch_reference: float | None = None) -> None:
332332
"ReplayGain tagging is enabled. Proceeding with tag calculation/application."
333333
)
334334
self._run_replaygain()
335-
336-
if not self.ffmpeg_normalize.replaygain:
335+
else:
336+
# Strip any existing ReplayGain tags from the output file
337+
# since they are no longer accurate after normalization
338+
self._strip_replaygain_tags(self.output_file)
337339
_logger.info(f"Normalized file written to {self.output_file}")
338340

339341
def _run_replaygain(self) -> None:
@@ -461,6 +463,103 @@ def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
461463
f"Successfully wrote replaygain tags to input file {self.input_file}"
462464
)
463465

466+
def _strip_replaygain_tags(self, output_file: str) -> None:
467+
"""
468+
Strip ReplayGain tags from the output file after normalization.
469+
470+
This ensures that old ReplayGain tags from the input are removed,
471+
since they are no longer accurate after normalization.
472+
473+
Args:
474+
output_file (str): Path to the output file to strip tags from
475+
"""
476+
_logger.debug(f"Stripping ReplayGain tags from {output_file}")
477+
478+
output_file_ext = os.path.splitext(output_file)[1]
479+
if output_file_ext == ".mp3":
480+
try:
481+
mp3 = MP3(output_file, ID3=ID3)
482+
if not mp3.tags:
483+
return
484+
# Remove REPLAYGAIN_* tags
485+
tags_to_remove = [
486+
key for key in mp3.tags if key.startswith("TXXX:REPLAYGAIN_")
487+
]
488+
for tag in tags_to_remove:
489+
del mp3.tags[tag]
490+
if tags_to_remove:
491+
mp3.save()
492+
_logger.debug(
493+
f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
494+
)
495+
except Exception as e:
496+
_logger.warning(
497+
f"Could not strip ReplayGain tags from {output_file}: {e}"
498+
)
499+
elif output_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
500+
try:
501+
mp4 = MP4(output_file)
502+
if not mp4.tags:
503+
return
504+
# Remove REPLAYGAIN_* tags
505+
tags_to_remove = [
506+
key
507+
for key in mp4.tags
508+
if "REPLAYGAIN_" in key.upper() or "REPLAYGAIN" in key.upper()
509+
]
510+
for tag in tags_to_remove:
511+
del mp4.tags[tag]
512+
if tags_to_remove:
513+
mp4.save()
514+
_logger.debug(
515+
f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
516+
)
517+
except Exception as e:
518+
_logger.warning(
519+
f"Could not strip ReplayGain tags from {output_file}: {e}"
520+
)
521+
elif output_file_ext == ".ogg":
522+
try:
523+
ogg = OggVorbis(output_file)
524+
# Remove REPLAYGAIN_* tags (case-insensitive)
525+
tags_to_remove = [
526+
key for key in ogg.keys() if key.upper().startswith("REPLAYGAIN_")
527+
]
528+
for tag in tags_to_remove:
529+
del ogg[tag]
530+
if tags_to_remove:
531+
ogg.save()
532+
_logger.debug(
533+
f"Stripped {len(tags_to_remove)} ReplayGain tag(s) from {output_file}"
534+
)
535+
except Exception as e:
536+
_logger.warning(
537+
f"Could not strip ReplayGain tags from {output_file}: {e}"
538+
)
539+
elif output_file_ext == ".opus":
540+
try:
541+
opus = OggOpus(output_file)
542+
# Remove R128_* tags (Opus uses R128 instead of REPLAYGAIN, case-insensitive)
543+
tags_to_remove = [
544+
key for key in opus.keys() if key.upper().startswith("R128_")
545+
]
546+
for tag in tags_to_remove:
547+
del opus[tag]
548+
if tags_to_remove:
549+
opus.save()
550+
_logger.debug(
551+
f"Stripped {len(tags_to_remove)} R128 tag(s) from {output_file}"
552+
)
553+
except Exception as e:
554+
_logger.warning(
555+
f"Could not strip ReplayGain tags from {output_file}: {e}"
556+
)
557+
else:
558+
# For other formats, we don't need to strip tags
559+
_logger.debug(
560+
f"Skipping ReplayGain tag stripping for {output_file_ext} (not supported/needed)"
561+
)
562+
464563
def _can_write_output_video(self) -> bool:
465564
"""
466565
Determine whether the output file can contain video at all.

tests/test_all.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,194 @@ def test_valid_file_passes_validation(self):
659659
ffmpeg_normalize_call(["tests/test.mp4", "-n"]) # dry run
660660
# No assertion needed - if validation fails, the command will error
661661

662+
def test_replaygain_tags_stripped_after_normalization(self, tmp_path):
663+
"""Test that ReplayGain tags are stripped after normalization."""
664+
import shutil
665+
from mutagen.id3 import ID3, TXXX
666+
from mutagen.mp3 import MP3
667+
668+
temp_input = tmp_path / "test_with_replaygain.mp3"
669+
temp_output = tmp_path / "normalized_output.mp3"
670+
671+
try:
672+
# Create a temporary copy of test.mp3 with ReplayGain tags
673+
shutil.copy("tests/test.mp3", temp_input)
674+
675+
# Add ReplayGain tags to the input file
676+
mp3 = MP3(str(temp_input), ID3=ID3)
677+
if not mp3.tags:
678+
mp3.add_tags()
679+
mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=["-5.00 dB"]))
680+
mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_PEAK", text=["0.950000"]))
681+
mp3.save()
682+
683+
# Verify tags were added
684+
mp3_check = MP3(str(temp_input), ID3=ID3)
685+
assert "TXXX:REPLAYGAIN_TRACK_GAIN" in mp3_check.tags
686+
assert "TXXX:REPLAYGAIN_TRACK_PEAK" in mp3_check.tags
687+
688+
# Normalize the file
689+
ffmpeg_normalize_call(
690+
[str(temp_input), "-o", str(temp_output), "-c:a", "libmp3lame"]
691+
)
692+
693+
# Check that output file exists
694+
assert temp_output.exists()
695+
696+
# Check that ReplayGain tags were stripped from the output
697+
mp3_output = MP3(str(temp_output), ID3=ID3)
698+
if mp3_output.tags:
699+
assert "TXXX:REPLAYGAIN_TRACK_GAIN" not in mp3_output.tags, (
700+
"REPLAYGAIN_TRACK_GAIN tag should be stripped"
701+
)
702+
assert "TXXX:REPLAYGAIN_TRACK_PEAK" not in mp3_output.tags, (
703+
"REPLAYGAIN_TRACK_PEAK tag should be stripped"
704+
)
705+
finally:
706+
# Clean up temp files even if test fails
707+
if temp_input.exists():
708+
temp_input.unlink()
709+
if temp_output.exists():
710+
temp_output.unlink()
711+
712+
def test_replaygain_tags_stripped_m4a(self, tmp_path):
713+
"""Test that ReplayGain tags are stripped from M4A files after normalization."""
714+
import shutil
715+
from mutagen.mp4 import MP4
716+
717+
temp_input = tmp_path / "test_with_replaygain.m4a"
718+
temp_output = tmp_path / "normalized_output.m4a"
719+
720+
try:
721+
# Create a temporary copy of test.m4a with ReplayGain tags
722+
shutil.copy("tests/test.m4a", temp_input)
723+
724+
# Add ReplayGain tags to the input file
725+
mp4 = MP4(str(temp_input))
726+
if not mp4.tags:
727+
mp4.add_tags()
728+
mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = [b"-5.00 dB"]
729+
mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = [b"0.950000"]
730+
mp4.save()
731+
732+
# Verify tags were added
733+
mp4_check = MP4(str(temp_input))
734+
assert "----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN" in mp4_check.tags
735+
assert "----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK" in mp4_check.tags
736+
737+
# Normalize the file
738+
ffmpeg_normalize_call(
739+
[str(temp_input), "-o", str(temp_output), "-c:a", "aac"]
740+
)
741+
742+
# Check that output file exists
743+
assert temp_output.exists()
744+
745+
# Check that ReplayGain tags were stripped from the output
746+
mp4_output = MP4(str(temp_output))
747+
if mp4_output.tags:
748+
assert (
749+
"----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN" not in mp4_output.tags
750+
), "REPLAYGAIN_TRACK_GAIN tag should be stripped"
751+
assert (
752+
"----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK" not in mp4_output.tags
753+
), "REPLAYGAIN_TRACK_PEAK tag should be stripped"
754+
finally:
755+
# Clean up temp files even if test fails
756+
if temp_input.exists():
757+
temp_input.unlink()
758+
if temp_output.exists():
759+
temp_output.unlink()
760+
761+
def test_replaygain_tags_stripped_ogg(self, tmp_path):
762+
"""Test that ReplayGain tags are stripped from OGG files after normalization."""
763+
import shutil
764+
from mutagen.oggvorbis import OggVorbis
765+
766+
temp_input = tmp_path / "test_with_replaygain.ogg"
767+
temp_output = tmp_path / "normalized_output.ogg"
768+
769+
try:
770+
# Create a temporary copy of test.ogg with ReplayGain tags
771+
shutil.copy("tests/test.ogg", temp_input)
772+
773+
# Add ReplayGain tags to the input file
774+
ogg = OggVorbis(str(temp_input))
775+
ogg["REPLAYGAIN_TRACK_GAIN"] = ["-5.00 dB"]
776+
ogg["REPLAYGAIN_TRACK_PEAK"] = ["0.950000"]
777+
ogg.save()
778+
779+
# Verify tags were added
780+
ogg_check = OggVorbis(str(temp_input))
781+
assert "REPLAYGAIN_TRACK_GAIN" in ogg_check
782+
assert "REPLAYGAIN_TRACK_PEAK" in ogg_check
783+
784+
# Normalize the file
785+
ffmpeg_normalize_call(
786+
[str(temp_input), "-o", str(temp_output), "-c:a", "libvorbis"]
787+
)
788+
789+
# Check that output file exists
790+
assert temp_output.exists()
791+
792+
# Check that ReplayGain tags were stripped from the output
793+
ogg_output = OggVorbis(str(temp_output))
794+
# OGG stores tags in lowercase
795+
assert "replaygain_track_gain" not in ogg_output, (
796+
"replaygain_track_gain tag should be stripped"
797+
)
798+
assert "replaygain_track_peak" not in ogg_output, (
799+
"replaygain_track_peak tag should be stripped"
800+
)
801+
finally:
802+
# Clean up temp files even if test fails
803+
if temp_input.exists():
804+
temp_input.unlink()
805+
if temp_output.exists():
806+
temp_output.unlink()
807+
808+
def test_replaygain_tags_stripped_opus(self, tmp_path):
809+
"""Test that R128 tags are stripped from OPUS files after normalization."""
810+
import shutil
811+
from mutagen.oggopus import OggOpus
812+
813+
temp_input = tmp_path / "test_with_r128.opus"
814+
temp_output = tmp_path / "normalized_output.opus"
815+
816+
try:
817+
# Create a temporary copy of test.opus with R128 tags
818+
shutil.copy("tests/test.opus", temp_input)
819+
820+
# Add R128 tags to the input file (Opus uses R128 instead of REPLAYGAIN)
821+
opus = OggOpus(str(temp_input))
822+
opus["R128_TRACK_GAIN"] = ["-1280"] # -5.00 dB * 256
823+
opus.save()
824+
825+
# Verify tags were added
826+
opus_check = OggOpus(str(temp_input))
827+
assert "R128_TRACK_GAIN" in opus_check
828+
829+
# Normalize the file
830+
ffmpeg_normalize_call(
831+
[str(temp_input), "-o", str(temp_output), "-c:a", "libopus"]
832+
)
833+
834+
# Check that output file exists
835+
assert temp_output.exists()
836+
837+
# Check that R128 tags were stripped from the output
838+
opus_output = OggOpus(str(temp_output))
839+
# OPUS stores tags in lowercase
840+
assert "r128_track_gain" not in opus_output, (
841+
"r128_track_gain tag should be stripped"
842+
)
843+
finally:
844+
# Clean up temp files even if test fails
845+
if temp_input.exists():
846+
temp_input.unlink()
847+
if temp_output.exists():
848+
temp_output.unlink()
849+
662850

663851
def test_ffmpeg_env():
664852
"""Verify that ffmpeg_env context manager sets environment correctly."""

0 commit comments

Comments
 (0)