ffmpeg-normalize 1.43.0__tar.gz → 1.44.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/PKG-INFO +1 -1
  2. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/pyproject.toml +1 -1
  3. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/pyproject.toml.orig +1 -1
  4. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__main__.py +97 -53
  5. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_cmd_utils.py +23 -3
  6. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +133 -9
  7. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_media_file.py +109 -1
  8. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_presets.py +6 -45
  9. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_streams.py +45 -4
  10. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/LICENSE.md +0 -0
  11. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/README.md +0 -0
  12. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__init__.py +0 -0
  13. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_errors.py +0 -0
  14. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_logger.py +0 -0
  15. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.43.0
3
+ Version: 1.44.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.43.0"
7
+ version = "1.44.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.43.0"
7
+ version = "1.44.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,7 +23,7 @@ __version__ = importlib.metadata.version("ffmpeg-normalize")
23
23
  _logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
- def create_parser() -> argparse.ArgumentParser:
26
+ def create_parser(suppress_defaults: bool = False) -> argparse.ArgumentParser:
27
27
  parser = argparse.ArgumentParser(
28
28
  prog="ffmpeg-normalize",
29
29
  description=textwrap.dedent(
@@ -76,6 +76,9 @@ def create_parser() -> argparse.ArgumentParser:
76
76
  will be written to the default output folder with the name `<input>.<ext>`,
77
77
  where `<ext>` is the output extension (see `-ext` option).
78
78
 
79
+ Output paths must be distinct and cannot overwrite another input in the same run,
80
+ even with --force. Normalizing a file in place is allowed.
81
+
79
82
  Example: ffmpeg-normalize 1.wav 2.wav -o 1n.wav 2n.wav
80
83
  """
81
84
  ),
@@ -215,12 +218,31 @@ def create_parser() -> argparse.ArgumentParser:
215
218
  output level, the output peak, the applied gain, and the status. The
216
219
  level is the integrated loudness for EBU, the RMS level for RMS, and the
217
220
  peak level for peak normalization. Values that are not available are
218
- shown as `-`.
221
+ shown as `-`. Output values are filter statistics (EBU) or calculated
222
+ from gain (RMS/peak). Use --verify for separate encoded-output measurements.
219
223
 
220
224
  Can be combined with `--print-stats`, which writes JSON to stdout.
221
225
  """
222
226
  ),
223
227
  )
228
+ group_normalization.add_argument(
229
+ "--verify",
230
+ action="store_true",
231
+ help="Measure the encoded output and fail if level or peak checks exceed tolerance. "
232
+ "Skipped during dry runs; incompatible with --replaygain.",
233
+ )
234
+ group_normalization.add_argument(
235
+ "--verify-loudness-tolerance",
236
+ type=float,
237
+ default=FFmpegNormalize.DEFAULTS["verify_loudness_tolerance"],
238
+ help="Allowed deviation from the effective output level in dB/LU (default: 0.5).",
239
+ )
240
+ group_normalization.add_argument(
241
+ "--verify-peak-tolerance",
242
+ type=float,
243
+ default=FFmpegNormalize.DEFAULTS["verify_peak_tolerance"],
244
+ help="Allowed overshoot above the output peak ceiling in dB (default: 0.1).",
245
+ )
224
246
  group_normalization.add_argument(
225
247
  "--replaygain",
226
248
  action="store_true",
@@ -243,8 +265,9 @@ def create_parser() -> argparse.ArgumentParser:
243
265
  level. However, a group of music files all from the same album is generally meant to be
244
266
  listened to at the relative volumes they were recorded at. In batch mode, all the specified
245
267
  files are considered to be part of a single album and their relative volumes are preserved.
246
- This is done by averaging the loudness of all the files, computing a single adjustment from
247
- that, and applying a relative adjustment to all the files.
268
+ RMS mode targets the average level, reducing the shared gain if needed to avoid sample
269
+ clipping. Peak mode sets the loudest sample across the album to the target level.
270
+ With --lower-only, the shared gain cannot be positive.
248
271
 
249
272
  Batch mode works with all normalization types (EBU, RMS, peak).
250
273
  """
@@ -672,11 +695,16 @@ def create_parser() -> argparse.ArgumentParser:
672
695
  ),
673
696
  default=FFmpegNormalize.DEFAULTS["extension"],
674
697
  )
698
+ if suppress_defaults:
699
+ for action in parser._actions:
700
+ action.default = argparse.SUPPRESS
675
701
  return parser
676
702
 
677
703
 
678
704
  def main() -> None:
679
- cli_args = create_parser().parse_args()
705
+ explicit_args = create_parser(suppress_defaults=True).parse_args()
706
+ cli_args = create_parser().parse_args([])
707
+ vars(cli_args).update(vars(explicit_args))
680
708
  setup_cli_logger(arguments=cli_args)
681
709
 
682
710
  def error(message: object) -> NoReturn:
@@ -707,7 +735,9 @@ def main() -> None:
707
735
  try:
708
736
  preset_data = preset_manager.load_preset(cli_args.preset)
709
737
  _logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
710
- preset_manager.merge_preset_with_args(preset_data, cli_args)
738
+ preset_manager.merge_preset_with_args(
739
+ preset_data, cli_args, set(vars(explicit_args))
740
+ )
711
741
  _logger.info(f"Applied preset '{cli_args.preset}'")
712
742
  except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
713
743
  error(str(e))
@@ -752,48 +782,54 @@ def main() -> None:
752
782
  if cli_args.audio_default_only and cli_args.audio_streams:
753
783
  error("Cannot use both --audio-default-only and --audio-streams together.")
754
784
 
755
- ffmpeg_normalize = FFmpegNormalize(
756
- normalization_type=cli_args.normalization_type,
757
- target_level=cli_args.target_level,
758
- print_stats=cli_args.print_stats,
759
- print_summary=cli_args.print_summary,
760
- threshold=cli_args.threshold,
761
- loudness_range_target=cli_args.loudness_range_target,
762
- keep_loudness_range_target=cli_args.keep_loudness_range_target,
763
- keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
764
- true_peak=cli_args.true_peak,
765
- offset=cli_args.offset,
766
- lower_only=cli_args.lower_only,
767
- auto_lower_loudness_target=cli_args.auto_lower_loudness_target,
768
- dual_mono=cli_args.dual_mono,
769
- dynamic=cli_args.dynamic,
770
- audio_codec=cli_args.audio_codec,
771
- audio_bitrate=cli_args.audio_bitrate,
772
- sample_rate=cli_args.sample_rate,
773
- audio_channels=cli_args.audio_channels,
774
- keep_original_audio=cli_args.keep_original_audio,
775
- pre_filter=cli_args.pre_filter,
776
- post_filter=cli_args.post_filter,
777
- video_codec=cli_args.video_codec,
778
- video_disable=cli_args.video_disable,
779
- subtitle_disable=cli_args.subtitle_disable,
780
- metadata_disable=cli_args.metadata_disable,
781
- chapters_disable=cli_args.chapters_disable,
782
- write_encoder_settings=cli_args.write_encoder_settings,
783
- extra_input_options=extra_input_options,
784
- extra_output_options=extra_output_options,
785
- output_format=cli_args.output_format,
786
- extension=cli_args.extension,
787
- dry_run=cli_args.dry_run,
788
- progress=cli_args.progress,
789
- replaygain=cli_args.replaygain,
790
- batch=cli_args.batch,
791
- audio_streams=audio_streams,
792
- audio_default_only=cli_args.audio_default_only,
793
- keep_other_audio=cli_args.keep_other_audio,
794
- keep_mtime=cli_args.keep_mtime,
795
- keep_bit_depth=cli_args.keep_bit_depth,
796
- )
785
+ try:
786
+ ffmpeg_normalize = FFmpegNormalize(
787
+ normalization_type=cli_args.normalization_type,
788
+ target_level=cli_args.target_level,
789
+ print_stats=cli_args.print_stats,
790
+ print_summary=cli_args.print_summary,
791
+ threshold=cli_args.threshold,
792
+ loudness_range_target=cli_args.loudness_range_target,
793
+ keep_loudness_range_target=cli_args.keep_loudness_range_target,
794
+ keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
795
+ true_peak=cli_args.true_peak,
796
+ offset=cli_args.offset,
797
+ lower_only=cli_args.lower_only,
798
+ auto_lower_loudness_target=cli_args.auto_lower_loudness_target,
799
+ dual_mono=cli_args.dual_mono,
800
+ dynamic=cli_args.dynamic,
801
+ audio_codec=cli_args.audio_codec,
802
+ audio_bitrate=cli_args.audio_bitrate,
803
+ sample_rate=cli_args.sample_rate,
804
+ audio_channels=cli_args.audio_channels,
805
+ keep_original_audio=cli_args.keep_original_audio,
806
+ pre_filter=cli_args.pre_filter,
807
+ post_filter=cli_args.post_filter,
808
+ video_codec=cli_args.video_codec,
809
+ video_disable=cli_args.video_disable,
810
+ subtitle_disable=cli_args.subtitle_disable,
811
+ metadata_disable=cli_args.metadata_disable,
812
+ chapters_disable=cli_args.chapters_disable,
813
+ write_encoder_settings=cli_args.write_encoder_settings,
814
+ extra_input_options=extra_input_options,
815
+ extra_output_options=extra_output_options,
816
+ output_format=cli_args.output_format,
817
+ extension=cli_args.extension,
818
+ dry_run=cli_args.dry_run,
819
+ progress=cli_args.progress,
820
+ replaygain=cli_args.replaygain,
821
+ batch=cli_args.batch,
822
+ audio_streams=audio_streams,
823
+ audio_default_only=cli_args.audio_default_only,
824
+ keep_other_audio=cli_args.keep_other_audio,
825
+ keep_mtime=cli_args.keep_mtime,
826
+ keep_bit_depth=cli_args.keep_bit_depth,
827
+ verify=cli_args.verify,
828
+ verify_loudness_tolerance=cli_args.verify_loudness_tolerance,
829
+ verify_peak_tolerance=cli_args.verify_peak_tolerance,
830
+ )
831
+ except FFmpegNormalizeError as e:
832
+ error(e)
797
833
 
798
834
  if cli_args.output and len(cli_args.input) > len(cli_args.output):
799
835
  _logger.warning(
@@ -825,6 +861,7 @@ def main() -> None:
825
861
  "Please fix the issues above and try again."
826
862
  )
827
863
 
864
+ output_files: list[str] = []
828
865
  for index, input_file in enumerate(input_files):
829
866
  if cli_args.output is not None and index < len(cli_args.output):
830
867
  if cli_args.output_folder and cli_args.output_folder != "normalized":
@@ -843,12 +880,15 @@ def main() -> None:
843
880
  + "."
844
881
  + cli_args.extension,
845
882
  )
846
- if not os.path.isdir(cli_args.output_folder) and not cli_args.dry_run:
847
- _logger.warning(
848
- f"Output directory '{cli_args.output_folder}' does not exist, will create"
849
- )
850
- os.makedirs(cli_args.output_folder, exist_ok=True)
883
+ output_files.append(output_file)
851
884
 
885
+ try:
886
+ if not cli_args.replaygain:
887
+ FFmpegNormalize.validate_output_files(input_files, output_files)
888
+ except FFmpegNormalizeError as e:
889
+ error(e)
890
+
891
+ for input_file, output_file in zip(input_files, output_files):
852
892
  if (
853
893
  os.path.exists(output_file)
854
894
  and not cli_args.force
@@ -867,6 +907,10 @@ def main() -> None:
867
907
  _logger.warning(f"Input file '{input_file}' is not a file, skipping")
868
908
  continue
869
909
 
910
+ output_dir = os.path.dirname(output_file)
911
+ if output_dir and not cli_args.dry_run:
912
+ os.makedirs(output_dir, exist_ok=True)
913
+
870
914
  try:
871
915
  ffmpeg_normalize.add_media_file(input_file, output_file)
872
916
  except FFmpegNormalizeError as e:
@@ -179,7 +179,11 @@ class CommandRunner:
179
179
  stderr = stderr_bytes.decode("utf8", errors="replace")
180
180
 
181
181
  if p.returncode != 0:
182
- raise RuntimeError(f"Error running command {shlex.join(cmd)}: {stderr}")
182
+ reason = (
183
+ stderr.strip()
184
+ or f"exited with status {p.returncode} without diagnostics"
185
+ )
186
+ raise RuntimeError(f"Error running command {shlex.join(cmd)}: {reason}")
183
187
 
184
188
  self.output = stdout + stderr
185
189
  return self
@@ -219,7 +223,9 @@ def get_ffmpeg_exe() -> str:
219
223
  if ff_path := os.getenv("FFMPEG_PATH"):
220
224
  if os.sep in ff_path:
221
225
  if not os.path.isfile(ff_path):
222
- raise FFmpegNormalizeError(f"No file exists at {ff_path}")
226
+ raise FFmpegNormalizeError(
227
+ f"ffmpeg executable does not exist: {ff_path}"
228
+ )
223
229
 
224
230
  return ff_path
225
231
 
@@ -392,7 +398,21 @@ def ffmpeg_has_loudnorm() -> bool:
392
398
  Returns:
393
399
  bool: True if loudnorm is supported, False otherwise
394
400
  """
395
- output = CommandRunner().run_command([get_ffmpeg_exe(), "-filters"]).get_output()
401
+ executable = get_ffmpeg_exe()
402
+ command = [executable, "-filters"]
403
+ try:
404
+ output = CommandRunner().run_command(command).get_output()
405
+ except (OSError, RuntimeError) as error:
406
+ _logger.debug("ffmpeg startup failed", exc_info=True)
407
+ reason = (
408
+ str(error)
409
+ .removeprefix(f"Error running command {shlex.join(command)}: ")
410
+ .strip()
411
+ )
412
+ reason = " ".join(reason.splitlines()[:2])
413
+ raise FFmpegNormalizeError(
414
+ f"Could not start ffmpeg '{executable}': {reason}"
415
+ ) from error
396
416
  supports_loudnorm = "loudnorm" in output
397
417
  if not supports_loudnorm:
398
418
  _logger.error(
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import logging
5
+ import math
5
6
  import os
6
7
  import sys
7
8
  from itertools import chain
@@ -98,6 +99,9 @@ class FFmpegNormalize:
98
99
  keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
99
100
  keep_mtime (bool, optional): Copy the input file's modification time to the output file. Defaults to False.
100
101
  keep_bit_depth (bool, optional): Carry the detected input bit depth through to the output encoder. Defaults to True.
102
+ verify (bool, optional): Measure the encoded output and check its effective target and peak ceiling. Defaults to False. Not available with ReplayGain; skipped during dry runs.
103
+ verify_loudness_tolerance (float, optional): Allowed level deviation in dB/LU. Defaults to 0.5.
104
+ verify_peak_tolerance (float, optional): Allowed peak overshoot in dB. Defaults to 0.1.
101
105
 
102
106
  Raises:
103
107
  FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
@@ -148,6 +152,9 @@ class FFmpegNormalize:
148
152
  "keep_other_audio": False,
149
153
  "keep_mtime": False,
150
154
  "keep_bit_depth": True,
155
+ "verify": False,
156
+ "verify_loudness_tolerance": 0.5,
157
+ "verify_peak_tolerance": 0.1,
151
158
  }
152
159
 
153
160
  def __init__(
@@ -193,6 +200,9 @@ class FFmpegNormalize:
193
200
  keep_other_audio: bool = False,
194
201
  keep_mtime: bool = False,
195
202
  keep_bit_depth: bool = True,
203
+ verify: bool = False,
204
+ verify_loudness_tolerance: float = 0.5,
205
+ verify_peak_tolerance: float = 0.1,
196
206
  ):
197
207
  self.ffmpeg_exe = get_ffmpeg_exe()
198
208
  self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
@@ -292,6 +302,20 @@ class FFmpegNormalize:
292
302
 
293
303
  self.keep_mtime = keep_mtime
294
304
  self.keep_bit_depth = keep_bit_depth
305
+ self.verify = verify
306
+ self.verify_loudness_tolerance = check_range(
307
+ verify_loudness_tolerance, 0, 99, name="verify_loudness_tolerance"
308
+ )
309
+ self.verify_peak_tolerance = check_range(
310
+ verify_peak_tolerance, 0, 99, name="verify_peak_tolerance"
311
+ )
312
+ if not all(
313
+ math.isfinite(value)
314
+ for value in (self.verify_loudness_tolerance, self.verify_peak_tolerance)
315
+ ):
316
+ raise FFmpegNormalizeError("Verification tolerances must be finite")
317
+ if verify and replaygain:
318
+ raise FFmpegNormalizeError("--verify cannot be combined with --replaygain")
295
319
 
296
320
  if self.output_format in PCM_INCOMPATIBLE_FORMATS:
297
321
  if self.audio_codec is not None and "pcm" in self.audio_codec:
@@ -385,23 +409,68 @@ class FFmpegNormalize:
385
409
  errors.append(error_msg)
386
410
  return errors
387
411
 
412
+ @staticmethod
413
+ def validate_output_files(input_files: list[str], output_files: list[str]) -> None:
414
+ """Reject duplicate destinations and outputs that overwrite another input.
415
+
416
+ Resolve symlinks and existing hard links, while allowing each file to be
417
+ normalized in place and multiple outputs to the null device.
418
+ """
419
+
420
+ def path_keys(path: str) -> list[str | tuple[int, int]]:
421
+ keys: list[str | tuple[int, int]] = [
422
+ os.path.normcase(os.path.realpath(path))
423
+ ]
424
+ if os.path.exists(path):
425
+ stat = os.stat(path)
426
+ keys.append((stat.st_dev, stat.st_ino))
427
+ return keys
428
+
429
+ input_owners: dict[str | tuple[int, int], set[int]] = {}
430
+ for index, path in enumerate(input_files):
431
+ for key in path_keys(path):
432
+ input_owners.setdefault(key, set()).add(index)
433
+
434
+ output_owners: dict[str | tuple[int, int], str] = {}
435
+ for index, path in enumerate(output_files):
436
+ if os.path.realpath(path) == os.path.realpath(os.devnull):
437
+ continue
438
+ for key in path_keys(path):
439
+ if key in output_owners:
440
+ raise FFmpegNormalizeError(
441
+ f"Output collision: '{path}' and '{output_owners[key]}' "
442
+ "refer to the same destination. Specify distinct output paths."
443
+ )
444
+ if input_owners.get(key, set()) - {index}:
445
+ raise FFmpegNormalizeError(
446
+ f"Output '{path}' would overwrite another input file. "
447
+ "Specify a different output path."
448
+ )
449
+ output_owners[key] = path
450
+
388
451
  def _calculate_batch_reference(self) -> float | None:
389
452
  """
390
- Calculate the batch reference loudness by averaging measurements across all files.
453
+ Calculate a shared reference from the selected streams' measurements.
454
+
455
+ Peak mode uses the highest sample peak. RMS mode uses the average RMS,
456
+ with gain capped by the highest sample peak. EBU uses average LUFS.
391
457
 
392
458
  Returns:
393
459
  float | None: The batch reference loudness value, or None if no measurements found.
394
460
 
395
461
  Note:
396
- TODO: Add option to specify different averaging methods (duration-weighted,
397
- use quietest/loudest track, etc.)
462
+ RMS and EBU averages weight each selected stream equally.
398
463
  """
399
464
  measurements: list[float] = []
465
+ peaks: list[float] = []
400
466
 
401
467
  for media_file in self.media_files:
402
- # Access audio streams from the streams dict
403
- audio_streams = media_file.streams.get("audio", {})
404
- for stream in audio_streams.values():
468
+ if media_file.status == "error":
469
+ continue
470
+ for stream in media_file._get_streams_to_normalize():
471
+ peak = stream.loudness_statistics.get("max")
472
+ if peak is not None and math.isfinite(peak):
473
+ peaks.append(float(peak))
405
474
  if self.normalization_type == "ebu":
406
475
  # Get EBU integrated loudness from first pass
407
476
  ebu_stats = stream.loudness_statistics.get("ebu_pass1")
@@ -418,6 +487,7 @@ class FFmpegNormalize:
418
487
  if max_val is not None:
419
488
  measurements.append(float(max_val))
420
489
 
490
+ measurements = [value for value in measurements if math.isfinite(value)]
421
491
  if not measurements:
422
492
  _logger.warning(
423
493
  "No loudness measurements found for batch reference calculation. "
@@ -425,12 +495,27 @@ class FFmpegNormalize:
425
495
  )
426
496
  return None
427
497
 
428
- # Simple average of all measurements
429
- batch_reference = sum(measurements) / len(measurements)
498
+ # Peak mode targets the loudest sample in the album. RMS mode targets
499
+ # the average level, but limits the shared gain to available headroom.
500
+ batch_reference = (
501
+ max(measurements)
502
+ if self.normalization_type == "peak"
503
+ else sum(measurements) / len(measurements)
504
+ )
505
+ if self.normalization_type == "rms" and peaks:
506
+ safe_reference = self.target_level + max(peaks)
507
+ if safe_reference > batch_reference:
508
+ _logger.warning(
509
+ "Reducing album gain to avoid sample clipping; "
510
+ "the requested RMS target will not be reached."
511
+ )
512
+ batch_reference = safe_reference
513
+ if self.lower_only and self.normalization_type in ("peak", "rms"):
514
+ batch_reference = max(batch_reference, self.target_level)
430
515
  _logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
431
516
  _logger.info(
432
517
  f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
433
- f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
518
+ f"({self.normalization_type.upper()}, based on {len(measurements)} stream(s))"
434
519
  )
435
520
 
436
521
  return batch_reference
@@ -446,6 +531,22 @@ class FFmpegNormalize:
446
531
  In non-batch mode, each file is processed completely (both passes) before
447
532
  moving to the next file.
448
533
  """
534
+ if (
535
+ self.verify
536
+ and not self.dry_run
537
+ and any(
538
+ os.path.realpath(file.output_file) == os.path.realpath(os.devnull)
539
+ for file in self.media_files
540
+ )
541
+ ):
542
+ raise FFmpegNormalizeError(
543
+ "--verify requires an output file, not the null device"
544
+ )
545
+ if not self.replaygain:
546
+ self.validate_output_files(
547
+ [media_file.input_file for media_file in self.media_files],
548
+ [media_file.output_file for media_file in self.media_files],
549
+ )
449
550
  if self.batch:
450
551
  # Batch mode: analyze all files first, then normalize with relative adjustments
451
552
  _logger.info(
@@ -524,6 +625,8 @@ class FFmpegNormalize:
524
625
 
525
626
  try:
526
627
  media_file.run_normalization(batch_reference=batch_reference)
628
+ if self.verify and not self.dry_run:
629
+ media_file.verify_output()
527
630
  except Exception as e:
528
631
  media_file.status = "error"
529
632
  media_file.error = str(e)
@@ -542,6 +645,8 @@ class FFmpegNormalize:
542
645
 
543
646
  try:
544
647
  media_file.run_normalization()
648
+ if self.verify and not self.dry_run:
649
+ media_file.verify_output()
545
650
  except Exception as e:
546
651
  media_file.status = "error"
547
652
  media_file.error = str(e)
@@ -652,4 +757,23 @@ class FFmpegNormalize:
652
757
  lines = [f"Summary ({header}):", "", fmt_line(headings)]
653
758
  lines.append(fmt_line(["-" * w for w in widths]))
654
759
  lines.extend(fmt_line(line) for line in table)
760
+ lines.append(
761
+ "\nOutput values above: loudnorm filter statistics for EBU; "
762
+ "calculated from applied gain for RMS/peak; input levels for skipped files."
763
+ )
764
+ verified = [row for row in rows if "verification" in row]
765
+ if verified:
766
+ level_unit = "LUFS" if self.normalization_type == "ebu" else "dBFS"
767
+ peak_unit = "dBTP" if self.normalization_type == "ebu" else "dBFS"
768
+ lines.append("\nEncoded-output verification (measured after encoding):")
769
+ for row in verified:
770
+ result = row["verification"]
771
+ outcome = "passed" if result["passed"] else "FAILED"
772
+ lines.append(
773
+ f"{row['output_file']} stream {result['output_stream_id']}: "
774
+ f"level {fmt(result['output_level'])} {level_unit}, "
775
+ f"peak {fmt(result['output_peak'])} {peak_unit}, {outcome}"
776
+ )
777
+ elif self.verify and self.dry_run:
778
+ lines.append("\nEncoded-output verification skipped: dry run.")
655
779
  return "\n".join(lines)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import math
5
+ from copy import copy
4
6
  import os
5
7
  import re
6
8
  import shlex
@@ -29,6 +31,7 @@ from ._streams import (
29
31
  SubtitleStream,
30
32
  SummaryRow,
31
33
  VideoStream,
34
+ VerificationResult,
32
35
  )
33
36
 
34
37
  if TYPE_CHECKING:
@@ -383,6 +386,108 @@ class MediaFile:
383
386
  self._apply_input_timestamps()
384
387
  _logger.info(f"Normalized file written to {self.output_file}")
385
388
 
389
+ def verify_output(self) -> None:
390
+ """Verify encoded audio, restoring requested timestamps after reading it."""
391
+ try:
392
+ self._verify_output()
393
+ finally:
394
+ if self.ffmpeg_normalize.keep_mtime:
395
+ self._apply_input_timestamps()
396
+
397
+ def _verify_output(self) -> None:
398
+ """Measure encoded audio without reapplying filters or input options.
399
+
400
+ Only normalized streams are checked. They precede passthrough and
401
+ original audio in encoded outputs; skipped files retain input indices.
402
+ Verification keeps the encoded file even if a check fails.
403
+ """
404
+ config = copy(self.ffmpeg_normalize)
405
+ config.pre_filter = config.post_filter = None
406
+ config.extra_input_options = None
407
+ config.audio_channels = None
408
+ measured_file = MediaFile(config, self.output_file, os.devnull)
409
+ selected = self._get_streams_to_normalize()
410
+ output_streams = list(measured_file.streams["audio"].values())
411
+ if self.status == "skipped":
412
+ output_streams = [
413
+ measured_file.streams["audio"][stream.stream_id] for stream in selected
414
+ ]
415
+ if not selected or len(output_streams) < len(selected):
416
+ raise FFmpegNormalizeError(
417
+ "Cannot verify output: selected audio streams are missing"
418
+ )
419
+
420
+ failures: list[str] = []
421
+ for source, measured in zip(selected, output_streams):
422
+ summary = source.get_summary()
423
+ expected = summary["output_level"]
424
+ if config.normalization_type == "ebu":
425
+ for _ in measured.parse_loudnorm_stats():
426
+ pass
427
+ stats = measured.loudness_statistics["ebu_pass1"]
428
+ if stats is None:
429
+ raise FFmpegNormalizeError(
430
+ "Cannot verify output: loudness measurement is missing"
431
+ )
432
+ level, peak = float(stats["input_i"]), float(stats["input_tp"])
433
+ if self.status != "skipped":
434
+ expected = source.verification_target_level
435
+ if expected is None:
436
+ expected = summary["input_level"]
437
+ peak_limit = config.true_peak
438
+ else:
439
+ for _ in measured.parse_astats():
440
+ pass
441
+ level = measured.loudness_statistics[
442
+ "mean" if config.normalization_type == "rms" else "max"
443
+ ]
444
+ peak = measured.loudness_statistics["max"]
445
+ peak_limit = (
446
+ config.target_level if config.normalization_type == "peak" else 0.0
447
+ )
448
+
449
+ if self.status == "skipped" and summary["output_peak"] is not None:
450
+ peak_limit = summary["output_peak"]
451
+
452
+ def finite(value: float | None) -> float | None:
453
+ return value if value is not None and math.isfinite(value) else None
454
+
455
+ level, peak, expected = finite(level), finite(peak), finite(expected)
456
+ errors: list[str] = []
457
+ if level is None or expected is None:
458
+ errors.append("output level or expected level is not finite")
459
+ elif abs(level - expected) > config.verify_loudness_tolerance:
460
+ errors.append(
461
+ f"level {level:.2f} differs from expected {expected:.2f} "
462
+ f"by more than {config.verify_loudness_tolerance:g} dB/LU"
463
+ )
464
+ if peak is None:
465
+ errors.append("output peak is not finite")
466
+ elif peak > peak_limit + config.verify_peak_tolerance:
467
+ errors.append(
468
+ f"peak {peak:.2f} exceeds ceiling {peak_limit:.2f} "
469
+ f"plus {config.verify_peak_tolerance:g} dB tolerance"
470
+ )
471
+ result: VerificationResult = {
472
+ "output_stream_id": measured.stream_id,
473
+ "output_level": level,
474
+ "output_peak": peak,
475
+ "expected_level": expected,
476
+ "peak_limit": peak_limit,
477
+ "loudness_tolerance": config.verify_loudness_tolerance,
478
+ "peak_tolerance": config.verify_peak_tolerance,
479
+ "passed": not errors,
480
+ "errors": errors,
481
+ }
482
+ source.verification = result
483
+ if errors:
484
+ failures.append(f"stream {source.stream_id}: " + "; ".join(errors))
485
+ if failures:
486
+ raise FFmpegNormalizeError(
487
+ "Encoded-output verification failed: " + "; ".join(failures)
488
+ )
489
+ _logger.info(f"Encoded-output verification passed for {self.output_file}")
490
+
386
491
  def _is_within_threshold(self) -> bool:
387
492
  """
388
493
  Return whether every stream selected for normalization is already
@@ -784,7 +889,10 @@ class MediaFile:
784
889
  list[str]: the filters, in application order
785
890
  """
786
891
  skip_normalization = False
787
- if self.ffmpeg_normalize.lower_only:
892
+ if self.ffmpeg_normalize.lower_only and not (
893
+ self.batch_reference is not None
894
+ and self.ffmpeg_normalize.normalization_type in ("peak", "rms")
895
+ ):
788
896
  if self.ffmpeg_normalize.normalization_type == "ebu":
789
897
  if (
790
898
  audio_stream.loudness_statistics["ebu_pass1"] is not None
@@ -9,8 +9,6 @@ import shutil
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
- from ._ffmpeg_normalize import FFmpegNormalize
13
-
14
12
  _logger = logging.getLogger(__name__)
15
13
 
16
14
 
@@ -171,60 +169,23 @@ class PresetManager:
171
169
  return preset_data
172
170
 
173
171
  def merge_preset_with_args(
174
- self, preset_data: dict[str, Any], cli_args: Any
172
+ self, preset_data: dict[str, Any], cli_args: Any, explicit_args: set[str]
175
173
  ) -> None:
176
- """Merge preset data with CLI arguments, giving precedence to CLI args.
174
+ """Apply preset values only to options omitted from the command line.
177
175
 
178
176
  Args:
179
- preset_data: Dictionary of preset configuration
180
- cli_args: Parsed CLI arguments (argparse Namespace object)
181
-
182
- The CLI arguments take precedence over preset values. This function modifies
183
- cli_args in place by setting attributes that were not explicitly provided
184
- on the command line.
177
+ preset_data: Preset configuration.
178
+ cli_args: Parsed arguments, including defaults; modified in place.
179
+ explicit_args: Argument destinations explicitly supplied by the user.
185
180
  """
186
181
  for key, value in preset_data.items():
187
- # Convert hyphens to underscores to match argparse attribute names
188
182
  attr_name = key.replace("-", "_")
189
-
190
- # Check if this attribute exists in cli_args
191
183
  if not hasattr(cli_args, attr_name):
192
184
  _logger.warning(
193
185
  f"Preset option '{key}' is not a valid ffmpeg-normalize option. Skipping."
194
186
  )
195
187
  continue
196
-
197
- # Get the current value
198
- current_value = getattr(cli_args, attr_name)
199
-
200
- # Check if this was explicitly set by the user
201
- # For most types, we can infer this by checking against the defaults:
202
- # - None values were not explicitly set
203
- # - Empty lists were not explicitly set
204
- # - False boolean flags were not explicitly set
205
- # - Default numeric values (specific to each option) need special handling
206
-
207
- should_apply = False
208
-
209
- if isinstance(value, bool):
210
- # For boolean flags, apply preset only if currently False (not set)
211
- should_apply = not current_value
212
- elif isinstance(current_value, list):
213
- # For lists (like output), apply preset only if empty
214
- should_apply = not current_value
215
- elif current_value is None:
216
- # For None values, always apply preset (not explicitly set)
217
- should_apply = True
218
- else:
219
- # For other values (numbers, strings), check if they match known defaults
220
- # This is conservative: only override if the value is a known default
221
- if (
222
- attr_name in FFmpegNormalize.DEFAULTS
223
- and current_value == FFmpegNormalize.DEFAULTS[attr_name]
224
- ):
225
- should_apply = True
226
-
227
- if should_apply:
188
+ if attr_name not in explicit_args:
228
189
  setattr(cli_args, attr_name, value)
229
190
  _logger.debug(f"Applied preset option '{key}' = {value}")
230
191
 
@@ -59,7 +59,23 @@ class LoudnessStatistics(TypedDict):
59
59
  max: float | None
60
60
 
61
61
 
62
- class SummaryRow(TypedDict):
62
+ class VerificationResult(TypedDict):
63
+ output_stream_id: int
64
+ output_level: float | None
65
+ output_peak: float | None
66
+ expected_level: float | None
67
+ peak_limit: float
68
+ loudness_tolerance: float
69
+ peak_tolerance: float
70
+ passed: bool
71
+ errors: list[str]
72
+
73
+
74
+ class _OptionalVerification(TypedDict, total=False):
75
+ verification: VerificationResult
76
+
77
+
78
+ class SummaryRow(_OptionalVerification):
63
79
  input_file: str
64
80
  output_file: str
65
81
  stream_id: int
@@ -68,9 +84,10 @@ class SummaryRow(TypedDict):
68
84
  output_level: float | None
69
85
  output_peak: float | None
70
86
  gain: float | None
87
+ output_source: str
71
88
 
72
89
 
73
- class _OptionalStatisticsMetadata(TypedDict, total=False):
90
+ class _OptionalStatisticsMetadata(_OptionalVerification, total=False):
74
91
  error: str
75
92
 
76
93
 
@@ -165,6 +182,8 @@ class AudioStream(MediaStream):
165
182
  # when normalization is skipped for this stream (e.g. with lower_only).
166
183
  # Stays None for EBU, where the gain is derived from the second pass stats.
167
184
  self.applied_gain: float | None = None
185
+ self.verification_target_level: float | None = None
186
+ self.verification: VerificationResult | None = None
168
187
 
169
188
  self.sample_rate = sample_rate
170
189
  self.bit_depth = bit_depth
@@ -218,6 +237,8 @@ class AudioStream(MediaStream):
218
237
  "max": self.loudness_statistics["max"],
219
238
  "status": self.media_file.status,
220
239
  }
240
+ if self.verification is not None:
241
+ stats["verification"] = self.verification
221
242
  # Only present when the file failed to process, per the per-file outcome
222
243
  # reporting (status is "error").
223
244
  if self.media_file.error is not None:
@@ -232,7 +253,9 @@ class AudioStream(MediaStream):
232
253
  second pass in dynamic mode), and the output level, output true peak and
233
254
  gain come from the second pass. For RMS and peak, the input level is the
234
255
  measured RMS or peak level, and the output values are computed from the
235
- applied gain. Values that are not available are None.
256
+ applied gain. Values that are not available are None. The output_source
257
+ field labels these values; verification, when present, contains separate
258
+ measurements of the encoded output and the check results.
236
259
 
237
260
  Returns:
238
261
  dict: A dictionary with the summary values.
@@ -285,7 +308,19 @@ class AudioStream(MediaStream):
285
308
  if status == "error":
286
309
  output_level = output_peak = gain = None
287
310
 
288
- return {
311
+ source = "unavailable"
312
+ if output_level is not None:
313
+ source = (
314
+ "input"
315
+ if status == "skipped"
316
+ else (
317
+ "filter"
318
+ if normalization_type == "ebu"
319
+ and self.loudness_statistics["ebu_pass2"] is not None
320
+ else "calculated"
321
+ )
322
+ )
323
+ summary: SummaryRow = {
289
324
  "input_file": self.media_file.input_file,
290
325
  "output_file": self.media_file.output_file,
291
326
  "stream_id": self.stream_id,
@@ -294,7 +329,11 @@ class AudioStream(MediaStream):
294
329
  "output_level": output_level,
295
330
  "output_peak": output_peak,
296
331
  "gain": gain,
332
+ "output_source": source,
297
333
  }
334
+ if self.verification is not None:
335
+ summary["verification"] = self.verification
336
+ return summary
298
337
 
299
338
  def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
300
339
  """
@@ -701,6 +740,7 @@ class AudioStream(MediaStream):
701
740
  if self.media_file.ffmpeg_normalize.dual_mono:
702
741
  opts["dual_mono"] = "true"
703
742
 
743
+ self.verification_target_level = float(opts["i"])
704
744
  return "loudnorm=" + dict_to_filter_opts(opts)
705
745
 
706
746
  if not self.loudness_statistics["ebu_pass1"]:
@@ -843,6 +883,7 @@ class AudioStream(MediaStream):
843
883
  if self.media_file.ffmpeg_normalize.dual_mono:
844
884
  opts["dual_mono"] = "true"
845
885
 
886
+ self.verification_target_level = target_level
846
887
  return "loudnorm=" + dict_to_filter_opts(opts)
847
888
 
848
889
  def get_second_pass_opts_peakrms(self, batch_reference: float | None = None) -> str: