ffmpeg-normalize 1.42.1__tar.gz → 1.43.1__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.42.1 → ffmpeg_normalize-1.43.1}/PKG-INFO +2 -1
  2. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/README.md +1 -0
  3. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/pyproject.toml +1 -1
  4. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/pyproject.toml.orig +1 -1
  5. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__main__.py +47 -10
  6. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py +175 -10
  7. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_media_file.py +26 -1
  8. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_presets.py +6 -45
  9. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_streams.py +90 -0
  10. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/LICENSE.md +0 -0
  11. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__init__.py +0 -0
  12. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
  13. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_errors.py +0 -0
  14. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_logger.py +0 -0
  15. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/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.42.1
3
+ Version: 1.43.1
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -55,6 +55,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
55
55
  - Peak normalization — Adjust audio to a specific peak level
56
56
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
57
57
  - Skip files already at target — Avoid re-encoding files already within a threshold of the target level
58
+ - Before/after summary — Print a table of input and output levels and the applied gain for all files with `--print-summary`
58
59
  - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
59
60
  - Video file support — Process video files while preserving video streams
60
61
  - Docker support — Run via Docker container
@@ -23,6 +23,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
23
23
  - Peak normalization — Adjust audio to a specific peak level
24
24
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
25
25
  - Skip files already at target — Avoid re-encoding files already within a threshold of the target level
26
+ - Before/after summary — Print a table of input and output levels and the applied gain for all files with `--print-summary`
26
27
  - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
27
28
  - Video file support — Process video files while preserving video streams
28
29
  - Docker support — Run via Docker container
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.42.1"
7
+ version = "1.43.1"
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.42.1"
7
+ version = "1.43.1"
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
  ),
@@ -204,6 +207,23 @@ def create_parser() -> argparse.ArgumentParser:
204
207
  action="store_true",
205
208
  help="Print loudness statistics for both passes formatted as JSON to stdout.",
206
209
  )
210
+ group_normalization.add_argument(
211
+ "--print-summary",
212
+ action="store_true",
213
+ help=textwrap.dedent(
214
+ """\
215
+ Print a before/after summary table to stderr after processing.
216
+
217
+ For each file and audio stream, the table lists the input level, the
218
+ output level, the output peak, the applied gain, and the status. The
219
+ level is the integrated loudness for EBU, the RMS level for RMS, and the
220
+ peak level for peak normalization. Values that are not available are
221
+ shown as `-`.
222
+
223
+ Can be combined with `--print-stats`, which writes JSON to stdout.
224
+ """
225
+ ),
226
+ )
207
227
  group_normalization.add_argument(
208
228
  "--replaygain",
209
229
  action="store_true",
@@ -226,8 +246,9 @@ def create_parser() -> argparse.ArgumentParser:
226
246
  level. However, a group of music files all from the same album is generally meant to be
227
247
  listened to at the relative volumes they were recorded at. In batch mode, all the specified
228
248
  files are considered to be part of a single album and their relative volumes are preserved.
229
- This is done by averaging the loudness of all the files, computing a single adjustment from
230
- that, and applying a relative adjustment to all the files.
249
+ RMS mode targets the average level, reducing the shared gain if needed to avoid sample
250
+ clipping. Peak mode sets the loudest sample across the album to the target level.
251
+ With --lower-only, the shared gain cannot be positive.
231
252
 
232
253
  Batch mode works with all normalization types (EBU, RMS, peak).
233
254
  """
@@ -655,11 +676,16 @@ def create_parser() -> argparse.ArgumentParser:
655
676
  ),
656
677
  default=FFmpegNormalize.DEFAULTS["extension"],
657
678
  )
679
+ if suppress_defaults:
680
+ for action in parser._actions:
681
+ action.default = argparse.SUPPRESS
658
682
  return parser
659
683
 
660
684
 
661
685
  def main() -> None:
662
- cli_args = create_parser().parse_args()
686
+ explicit_args = create_parser(suppress_defaults=True).parse_args()
687
+ cli_args = create_parser().parse_args([])
688
+ vars(cli_args).update(vars(explicit_args))
663
689
  setup_cli_logger(arguments=cli_args)
664
690
 
665
691
  def error(message: object) -> NoReturn:
@@ -690,7 +716,9 @@ def main() -> None:
690
716
  try:
691
717
  preset_data = preset_manager.load_preset(cli_args.preset)
692
718
  _logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
693
- preset_manager.merge_preset_with_args(preset_data, cli_args)
719
+ preset_manager.merge_preset_with_args(
720
+ preset_data, cli_args, set(vars(explicit_args))
721
+ )
694
722
  _logger.info(f"Applied preset '{cli_args.preset}'")
695
723
  except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
696
724
  error(str(e))
@@ -739,6 +767,7 @@ def main() -> None:
739
767
  normalization_type=cli_args.normalization_type,
740
768
  target_level=cli_args.target_level,
741
769
  print_stats=cli_args.print_stats,
770
+ print_summary=cli_args.print_summary,
742
771
  threshold=cli_args.threshold,
743
772
  loudness_range_target=cli_args.loudness_range_target,
744
773
  keep_loudness_range_target=cli_args.keep_loudness_range_target,
@@ -807,6 +836,7 @@ def main() -> None:
807
836
  "Please fix the issues above and try again."
808
837
  )
809
838
 
839
+ output_files: list[str] = []
810
840
  for index, input_file in enumerate(input_files):
811
841
  if cli_args.output is not None and index < len(cli_args.output):
812
842
  if cli_args.output_folder and cli_args.output_folder != "normalized":
@@ -825,12 +855,15 @@ def main() -> None:
825
855
  + "."
826
856
  + cli_args.extension,
827
857
  )
828
- if not os.path.isdir(cli_args.output_folder) and not cli_args.dry_run:
829
- _logger.warning(
830
- f"Output directory '{cli_args.output_folder}' does not exist, will create"
831
- )
832
- os.makedirs(cli_args.output_folder, exist_ok=True)
858
+ output_files.append(output_file)
833
859
 
860
+ try:
861
+ if not cli_args.replaygain:
862
+ FFmpegNormalize.validate_output_files(input_files, output_files)
863
+ except FFmpegNormalizeError as e:
864
+ error(e)
865
+
866
+ for input_file, output_file in zip(input_files, output_files):
834
867
  if (
835
868
  os.path.exists(output_file)
836
869
  and not cli_args.force
@@ -849,6 +882,10 @@ def main() -> None:
849
882
  _logger.warning(f"Input file '{input_file}' is not a file, skipping")
850
883
  continue
851
884
 
885
+ output_dir = os.path.dirname(output_file)
886
+ if output_dir and not cli_args.dry_run:
887
+ os.makedirs(output_dir, exist_ok=True)
888
+
852
889
  try:
853
890
  ffmpeg_normalize.add_media_file(input_file, output_file)
854
891
  except FFmpegNormalizeError as e:
@@ -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
@@ -21,7 +22,7 @@ from ._errors import FFmpegNormalizeError
21
22
  from ._media_file import MediaFile
22
23
 
23
24
  if TYPE_CHECKING:
24
- from ._streams import LoudnessStatisticsWithMetadata
25
+ from ._streams import LoudnessStatisticsWithMetadata, SummaryRow
25
26
 
26
27
  _logger = logging.getLogger(__name__)
27
28
 
@@ -60,6 +61,7 @@ class FFmpegNormalize:
60
61
  normalization_type (str, optional): Normalization type. Defaults to "ebu".
61
62
  target_level (float, optional): Target level. Defaults to -23.0.
62
63
  print_stats (bool, optional): Print loudnorm stats. Defaults to False.
64
+ print_summary (bool, optional): Print a before/after summary table to stderr after processing. Defaults to False.
63
65
  threshold (float, optional): When set to a positive value, skip normalization when the input is already within this many dB/LU of the target level, copying it through unchanged. Defaults to 0 (disabled, always normalize).
64
66
  loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
65
67
  keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
@@ -108,6 +110,7 @@ class FFmpegNormalize:
108
110
  "normalization_type": "ebu",
109
111
  "target_level": -23.0,
110
112
  "print_stats": False,
113
+ "print_summary": False,
111
114
  "threshold": 0.0,
112
115
  "loudness_range_target": 7.0,
113
116
  "keep_loudness_range_target": False,
@@ -153,6 +156,7 @@ class FFmpegNormalize:
153
156
  normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
154
157
  target_level: float = -23.0,
155
158
  print_stats: bool = False,
159
+ print_summary: bool = False,
156
160
  threshold: float = 0.0,
157
161
  loudness_range_target: float = 7.0,
158
162
  keep_loudness_range_target: bool = False,
@@ -212,6 +216,7 @@ class FFmpegNormalize:
212
216
  self.target_level = check_range(target_level, -99, 0, name="target_level")
213
217
 
214
218
  self.print_stats = print_stats
219
+ self.print_summary = print_summary
215
220
 
216
221
  self.threshold = check_range(threshold, 0, 99, name="threshold")
217
222
 
@@ -381,23 +386,68 @@ class FFmpegNormalize:
381
386
  errors.append(error_msg)
382
387
  return errors
383
388
 
389
+ @staticmethod
390
+ def validate_output_files(input_files: list[str], output_files: list[str]) -> None:
391
+ """Reject duplicate destinations and outputs that overwrite another input.
392
+
393
+ Resolve symlinks and existing hard links, while allowing each file to be
394
+ normalized in place and multiple outputs to the null device.
395
+ """
396
+
397
+ def path_keys(path: str) -> list[str | tuple[int, int]]:
398
+ keys: list[str | tuple[int, int]] = [
399
+ os.path.normcase(os.path.realpath(path))
400
+ ]
401
+ if os.path.exists(path):
402
+ stat = os.stat(path)
403
+ keys.append((stat.st_dev, stat.st_ino))
404
+ return keys
405
+
406
+ input_owners: dict[str | tuple[int, int], set[int]] = {}
407
+ for index, path in enumerate(input_files):
408
+ for key in path_keys(path):
409
+ input_owners.setdefault(key, set()).add(index)
410
+
411
+ output_owners: dict[str | tuple[int, int], str] = {}
412
+ for index, path in enumerate(output_files):
413
+ if os.path.realpath(path) == os.path.realpath(os.devnull):
414
+ continue
415
+ for key in path_keys(path):
416
+ if key in output_owners:
417
+ raise FFmpegNormalizeError(
418
+ f"Output collision: '{path}' and '{output_owners[key]}' "
419
+ "refer to the same destination. Specify distinct output paths."
420
+ )
421
+ if input_owners.get(key, set()) - {index}:
422
+ raise FFmpegNormalizeError(
423
+ f"Output '{path}' would overwrite another input file. "
424
+ "Specify a different output path."
425
+ )
426
+ output_owners[key] = path
427
+
384
428
  def _calculate_batch_reference(self) -> float | None:
385
429
  """
386
- Calculate the batch reference loudness by averaging measurements across all files.
430
+ Calculate a shared reference from the selected streams' measurements.
431
+
432
+ Peak mode uses the highest sample peak. RMS mode uses the average RMS,
433
+ with gain capped by the highest sample peak. EBU uses average LUFS.
387
434
 
388
435
  Returns:
389
436
  float | None: The batch reference loudness value, or None if no measurements found.
390
437
 
391
438
  Note:
392
- TODO: Add option to specify different averaging methods (duration-weighted,
393
- use quietest/loudest track, etc.)
439
+ RMS and EBU averages weight each selected stream equally.
394
440
  """
395
441
  measurements: list[float] = []
442
+ peaks: list[float] = []
396
443
 
397
444
  for media_file in self.media_files:
398
- # Access audio streams from the streams dict
399
- audio_streams = media_file.streams.get("audio", {})
400
- for stream in audio_streams.values():
445
+ if media_file.status == "error":
446
+ continue
447
+ for stream in media_file._get_streams_to_normalize():
448
+ peak = stream.loudness_statistics.get("max")
449
+ if peak is not None and math.isfinite(peak):
450
+ peaks.append(float(peak))
401
451
  if self.normalization_type == "ebu":
402
452
  # Get EBU integrated loudness from first pass
403
453
  ebu_stats = stream.loudness_statistics.get("ebu_pass1")
@@ -414,6 +464,7 @@ class FFmpegNormalize:
414
464
  if max_val is not None:
415
465
  measurements.append(float(max_val))
416
466
 
467
+ measurements = [value for value in measurements if math.isfinite(value)]
417
468
  if not measurements:
418
469
  _logger.warning(
419
470
  "No loudness measurements found for batch reference calculation. "
@@ -421,12 +472,27 @@ class FFmpegNormalize:
421
472
  )
422
473
  return None
423
474
 
424
- # Simple average of all measurements
425
- batch_reference = sum(measurements) / len(measurements)
475
+ # Peak mode targets the loudest sample in the album. RMS mode targets
476
+ # the average level, but limits the shared gain to available headroom.
477
+ batch_reference = (
478
+ max(measurements)
479
+ if self.normalization_type == "peak"
480
+ else sum(measurements) / len(measurements)
481
+ )
482
+ if self.normalization_type == "rms" and peaks:
483
+ safe_reference = self.target_level + max(peaks)
484
+ if safe_reference > batch_reference:
485
+ _logger.warning(
486
+ "Reducing album gain to avoid sample clipping; "
487
+ "the requested RMS target will not be reached."
488
+ )
489
+ batch_reference = safe_reference
490
+ if self.lower_only and self.normalization_type in ("peak", "rms"):
491
+ batch_reference = max(batch_reference, self.target_level)
426
492
  _logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
427
493
  _logger.info(
428
494
  f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
429
- f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
495
+ f"({self.normalization_type.upper()}, based on {len(measurements)} stream(s))"
430
496
  )
431
497
 
432
498
  return batch_reference
@@ -442,6 +508,11 @@ class FFmpegNormalize:
442
508
  In non-batch mode, each file is processed completely (both passes) before
443
509
  moving to the next file.
444
510
  """
511
+ if not self.replaygain:
512
+ self.validate_output_files(
513
+ [media_file.input_file for media_file in self.media_files],
514
+ [media_file.output_file for media_file in self.media_files],
515
+ )
445
516
  if self.batch:
446
517
  # Batch mode: analyze all files first, then normalize with relative adjustments
447
518
  _logger.info(
@@ -555,3 +626,97 @@ class FFmpegNormalize:
555
626
  indent=4,
556
627
  )
557
628
  print()
629
+
630
+ if self.print_summary:
631
+ print(self.format_summary(), file=sys.stderr)
632
+
633
+ def get_summary(self) -> list[SummaryRow]:
634
+ """
635
+ Return the before/after summary for all audio streams of all files.
636
+
637
+ Each entry contains the input level, output level, output peak and the
638
+ applied gain in dB. The level is the integrated loudness for EBU, the
639
+ RMS level for RMS, and the peak level for peak normalization. The
640
+ output peak is the true peak for EBU and the sample peak otherwise.
641
+ Values that are not available are None.
642
+
643
+ Returns:
644
+ list[SummaryRow]: One summary entry per audio stream
645
+ """
646
+ return list(
647
+ chain.from_iterable(
648
+ media_file.get_summary() for media_file in self.media_files
649
+ )
650
+ )
651
+
652
+ def format_summary(self) -> str:
653
+ """
654
+ Return the before/after summary as a human-readable table.
655
+
656
+ Returns:
657
+ str: The formatted table, including a header line with the target
658
+ """
659
+ if self.normalization_type == "ebu":
660
+ header = (
661
+ f"EBU R128 normalization, target: {self.target_level:.1f} LUFS, "
662
+ f"true peak: {self.true_peak:.1f} dBTP"
663
+ )
664
+ if self.dynamic:
665
+ header += ", dynamic mode"
666
+ columns = ["Input (LUFS)", "Output (LUFS)", "True peak (dBTP)", "Gain (dB)"]
667
+ elif self.normalization_type == "rms":
668
+ header = f"RMS normalization, target: {self.target_level:.1f} dBFS"
669
+ columns = [
670
+ "Input RMS (dBFS)",
671
+ "Output RMS (dBFS)",
672
+ "Peak (dBFS)",
673
+ "Gain (dB)",
674
+ ]
675
+ else:
676
+ header = f"Peak normalization, target: {self.target_level:.1f} dBFS"
677
+ columns = ["Input peak (dBFS)", "Output peak (dBFS)", "Gain (dB)"]
678
+
679
+ if self.batch:
680
+ header += ", batch mode (relative loudness preserved)"
681
+ if self.dry_run:
682
+ header += ", dry run"
683
+
684
+ rows = self.get_summary()
685
+ if not rows:
686
+ return f"Summary ({header}): no files processed"
687
+
688
+ def fmt(value: float | None, signed: bool = False) -> str:
689
+ if value is None:
690
+ return "-"
691
+ return f"{value:+.1f}" if signed else f"{value:.1f}"
692
+
693
+ table: list[list[str]] = []
694
+ for row in rows:
695
+ values = [fmt(row["input_level"]), fmt(row["output_level"])]
696
+ if self.normalization_type != "peak":
697
+ values.append(fmt(row["output_peak"]))
698
+ values.append(fmt(row["gain"], signed=True))
699
+ table.append(
700
+ [row["input_file"], str(row["stream_id"]), *values, row["status"]]
701
+ )
702
+
703
+ headings = ["File", "Stream", *columns, "Status"]
704
+ widths = [
705
+ max(len(headings[i]), *(len(line[i]) for line in table))
706
+ for i in range(len(headings))
707
+ ]
708
+
709
+ def fmt_line(cells: list[str]) -> str:
710
+ # Left-align the file name and status, right-align everything else
711
+ parts = [
712
+ cell.ljust(widths[i])
713
+ if i in (0, len(cells) - 1)
714
+ else cell.rjust(widths[i])
715
+ for i, cell in enumerate(cells)
716
+ ]
717
+ return " ".join(parts).rstrip()
718
+
719
+ lines = [f"Summary ({header}):", "", fmt_line(headings)]
720
+ lines.append(fmt_line(["-" * w for w in widths]))
721
+ lines.extend(fmt_line(line) for line in table)
722
+ return "\n".join(lines)
@@ -27,6 +27,7 @@ from ._streams import (
27
27
  AudioStream,
28
28
  LoudnessStatisticsWithMetadata,
29
29
  SubtitleStream,
30
+ SummaryRow,
30
31
  VideoStream,
31
32
  )
32
33
 
@@ -783,7 +784,10 @@ class MediaFile:
783
784
  list[str]: the filters, in application order
784
785
  """
785
786
  skip_normalization = False
786
- if self.ffmpeg_normalize.lower_only:
787
+ if self.ffmpeg_normalize.lower_only and not (
788
+ self.batch_reference is not None
789
+ and self.ffmpeg_normalize.normalization_type in ("peak", "rms")
790
+ ):
787
791
  if self.ffmpeg_normalize.normalization_type == "ebu":
788
792
  if (
789
793
  audio_stream.loudness_statistics["ebu_pass1"] is not None
@@ -811,6 +815,7 @@ class MediaFile:
811
815
  f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
812
816
  )
813
817
  normalization_filter = "acopy"
818
+ audio_stream.applied_gain = 0.0
814
819
  else:
815
820
  if self.ffmpeg_normalize.normalization_type == "ebu":
816
821
  normalization_filter = audio_stream.get_second_pass_opts_ebu(
@@ -1223,3 +1228,23 @@ class MediaFile:
1223
1228
  return (
1224
1229
  audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
1225
1230
  )
1231
+
1232
+ def get_summary(self) -> list[SummaryRow]:
1233
+ """
1234
+ Return the before/after summary for each analyzed audio stream.
1235
+
1236
+ Streams without any measurement (e.g. streams not selected for
1237
+ normalization) are left out, unless the file failed to process.
1238
+
1239
+ Returns:
1240
+ list[SummaryRow]: One summary entry per audio stream
1241
+ """
1242
+ rows = [
1243
+ audio_stream.get_summary()
1244
+ for audio_stream in self.streams["audio"].values()
1245
+ ]
1246
+ return [
1247
+ row
1248
+ for row in rows
1249
+ if row["status"] == "error" or row["input_level"] is not None
1250
+ ]
@@ -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,6 +59,17 @@ class LoudnessStatistics(TypedDict):
59
59
  max: float | None
60
60
 
61
61
 
62
+ class SummaryRow(TypedDict):
63
+ input_file: str
64
+ output_file: str
65
+ stream_id: int
66
+ status: str
67
+ input_level: float | None
68
+ output_level: float | None
69
+ output_peak: float | None
70
+ gain: float | None
71
+
72
+
62
73
  class _OptionalStatisticsMetadata(TypedDict, total=False):
63
74
  error: str
64
75
 
@@ -150,6 +161,11 @@ class AudioStream(MediaStream):
150
161
  "max": None,
151
162
  }
152
163
 
164
+ # Gain in dB applied by the second pass volume filter (peak/RMS), or 0
165
+ # when normalization is skipped for this stream (e.g. with lower_only).
166
+ # Stays None for EBU, where the gain is derived from the second pass stats.
167
+ self.applied_gain: float | None = None
168
+
153
169
  self.sample_rate = sample_rate
154
170
  self.bit_depth = bit_depth
155
171
 
@@ -208,6 +224,78 @@ class AudioStream(MediaStream):
208
224
  stats["error"] = self.media_file.error
209
225
  return stats
210
226
 
227
+ def get_summary(self) -> SummaryRow:
228
+ """
229
+ Return the before/after levels and the applied gain for the stream.
230
+
231
+ For EBU, the input level and true peak come from the first pass (or the
232
+ second pass in dynamic mode), and the output level, output true peak and
233
+ gain come from the second pass. For RMS and peak, the input level is the
234
+ measured RMS or peak level, and the output values are computed from the
235
+ applied gain. Values that are not available are None.
236
+
237
+ Returns:
238
+ dict: A dictionary with the summary values.
239
+ """
240
+ normalization_type = self.ffmpeg_normalize.normalization_type
241
+ status = self.media_file.status
242
+
243
+ input_level: float | None = None
244
+ input_peak: float | None = None
245
+ output_level: float | None = None
246
+ output_peak: float | None = None
247
+ gain: float | None = None
248
+
249
+ if normalization_type == "ebu":
250
+ pass1 = self.loudness_statistics["ebu_pass1"]
251
+ pass2 = self.loudness_statistics["ebu_pass2"]
252
+ input_stats = pass1 if pass1 is not None else pass2
253
+ if input_stats is not None:
254
+ input_level = float(input_stats["input_i"])
255
+ input_peak = float(input_stats["input_tp"])
256
+ if pass2 is not None:
257
+ output_level = float(pass2["output_i"])
258
+ output_peak = float(pass2["output_tp"])
259
+ # Input and output of the same pass, as the first pass input
260
+ # measurement can differ slightly from the second pass one
261
+ gain = output_level - float(pass2["input_i"])
262
+ else:
263
+ measured = (
264
+ self.loudness_statistics["mean"]
265
+ if normalization_type == "rms"
266
+ else self.loudness_statistics["max"]
267
+ )
268
+ if measured is not None:
269
+ input_level = float(measured)
270
+ if self.loudness_statistics["max"] is not None:
271
+ input_peak = float(self.loudness_statistics["max"])
272
+
273
+ applied_gain = 0.0 if status == "skipped" else self.applied_gain
274
+ if (
275
+ status != "error"
276
+ and output_level is None
277
+ and applied_gain is not None
278
+ and input_level is not None
279
+ ):
280
+ gain = applied_gain
281
+ output_level = input_level + applied_gain
282
+ if input_peak is not None:
283
+ output_peak = input_peak + applied_gain
284
+
285
+ if status == "error":
286
+ output_level = output_peak = gain = None
287
+
288
+ return {
289
+ "input_file": self.media_file.input_file,
290
+ "output_file": self.media_file.output_file,
291
+ "stream_id": self.stream_id,
292
+ "status": status,
293
+ "input_level": input_level,
294
+ "output_level": output_level,
295
+ "output_peak": output_peak,
296
+ "gain": gain,
297
+ }
298
+
211
299
  def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
212
300
  """
213
301
  Set the EBU loudness statistics for the second pass.
@@ -815,6 +903,8 @@ class AudioStream(MediaStream):
815
903
  f"Adjusting stream {self.stream_id} by {adjustment} dB to reach {target_level}"
816
904
  )
817
905
 
906
+ self.applied_gain = float(adjustment)
907
+
818
908
  clip_amount = self.loudness_statistics["max"] + adjustment
819
909
  if clip_amount > 0:
820
910
  _logger.warning(