ffmpeg-normalize 1.42.0__tar.gz → 1.43.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.42.0 → ffmpeg_normalize-1.43.0}/PKG-INFO +8 -2
  2. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/README.md +6 -1
  3. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/pyproject.toml +2 -1
  4. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/pyproject.toml.orig +2 -1
  5. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/__main__.py +18 -0
  6. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +99 -1
  7. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_media_file.py +22 -0
  8. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_streams.py +90 -0
  9. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/LICENSE.md +0 -0
  10. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/__init__.py +0 -0
  11. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
  12. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_errors.py +0 -0
  13. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_logger.py +0 -0
  14. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/_presets.py +0 -0
  15. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.42.0 → ffmpeg_normalize-1.43.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.42.0
3
+ Version: 1.43.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
22
23
  Requires-Dist: tqdm>=4.64.1
23
24
  Requires-Dist: colorama>=0.4.6 ; sys_platform == 'win32'
24
25
  Requires-Dist: ffmpeg-progress-yield>=1.0.1
@@ -29,7 +30,11 @@ Project-URL: Homepage, https://github.com/slhck/ffmpeg-normalize
29
30
  Project-URL: Repository, https://github.com/slhck/ffmpeg-normalize
30
31
  Description-Content-Type: text/markdown
31
32
 
32
- # ffmpeg-normalize
33
+ <h1 align="center">ffmpeg-normalize</h1>
34
+
35
+ <p align="center">
36
+ <img src="icon.png" alt="ffmpeg-normalize logo" width="160" height="160">
37
+ </p>
33
38
 
34
39
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
35
40
  [![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)](https://hub.docker.com/r/slhck/ffmpeg-normalize)
@@ -50,6 +55,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
50
55
  - Peak normalization — Adjust audio to a specific peak level
51
56
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
52
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`
53
59
  - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
54
60
  - Video file support — Process video files while preserving video streams
55
61
  - Docker support — Run via Docker container
@@ -1,4 +1,8 @@
1
- # ffmpeg-normalize
1
+ <h1 align="center">ffmpeg-normalize</h1>
2
+
3
+ <p align="center">
4
+ <img src="icon.png" alt="ffmpeg-normalize logo" width="160" height="160">
5
+ </p>
2
6
 
3
7
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
4
8
  [![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)](https://hub.docker.com/r/slhck/ffmpeg-normalize)
@@ -19,6 +23,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
19
23
  - Peak normalization — Adjust audio to a specific peak level
20
24
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
21
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`
22
27
  - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
23
28
  - Video file support — Process video files while preserving video streams
24
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.0"
7
+ version = "1.43.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.12",
28
28
  "Programming Language :: Python :: 3.13",
29
29
  "Programming Language :: Python :: 3.14",
30
+ "Programming Language :: Python :: 3.15",
30
31
  ]
31
32
  requires-python = ">=3.10"
32
33
  dependencies = [
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.42.0"
7
+ version = "1.43.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,6 +26,7 @@ classifiers = [
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
28
  "Programming Language :: Python :: 3.14",
29
+ "Programming Language :: Python :: 3.15",
29
30
  ]
30
31
  requires-python = ">=3.10"
31
32
  dependencies = [
@@ -204,6 +204,23 @@ def create_parser() -> argparse.ArgumentParser:
204
204
  action="store_true",
205
205
  help="Print loudness statistics for both passes formatted as JSON to stdout.",
206
206
  )
207
+ group_normalization.add_argument(
208
+ "--print-summary",
209
+ action="store_true",
210
+ help=textwrap.dedent(
211
+ """\
212
+ Print a before/after summary table to stderr after processing.
213
+
214
+ For each file and audio stream, the table lists the input level, the
215
+ output level, the output peak, the applied gain, and the status. The
216
+ level is the integrated loudness for EBU, the RMS level for RMS, and the
217
+ peak level for peak normalization. Values that are not available are
218
+ shown as `-`.
219
+
220
+ Can be combined with `--print-stats`, which writes JSON to stdout.
221
+ """
222
+ ),
223
+ )
207
224
  group_normalization.add_argument(
208
225
  "--replaygain",
209
226
  action="store_true",
@@ -739,6 +756,7 @@ def main() -> None:
739
756
  normalization_type=cli_args.normalization_type,
740
757
  target_level=cli_args.target_level,
741
758
  print_stats=cli_args.print_stats,
759
+ print_summary=cli_args.print_summary,
742
760
  threshold=cli_args.threshold,
743
761
  loudness_range_target=cli_args.loudness_range_target,
744
762
  keep_loudness_range_target=cli_args.keep_loudness_range_target,
@@ -21,7 +21,7 @@ from ._errors import FFmpegNormalizeError
21
21
  from ._media_file import MediaFile
22
22
 
23
23
  if TYPE_CHECKING:
24
- from ._streams import LoudnessStatisticsWithMetadata
24
+ from ._streams import LoudnessStatisticsWithMetadata, SummaryRow
25
25
 
26
26
  _logger = logging.getLogger(__name__)
27
27
 
@@ -60,6 +60,7 @@ class FFmpegNormalize:
60
60
  normalization_type (str, optional): Normalization type. Defaults to "ebu".
61
61
  target_level (float, optional): Target level. Defaults to -23.0.
62
62
  print_stats (bool, optional): Print loudnorm stats. Defaults to False.
63
+ print_summary (bool, optional): Print a before/after summary table to stderr after processing. Defaults to False.
63
64
  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
65
  loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
65
66
  keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
@@ -108,6 +109,7 @@ class FFmpegNormalize:
108
109
  "normalization_type": "ebu",
109
110
  "target_level": -23.0,
110
111
  "print_stats": False,
112
+ "print_summary": False,
111
113
  "threshold": 0.0,
112
114
  "loudness_range_target": 7.0,
113
115
  "keep_loudness_range_target": False,
@@ -153,6 +155,7 @@ class FFmpegNormalize:
153
155
  normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
154
156
  target_level: float = -23.0,
155
157
  print_stats: bool = False,
158
+ print_summary: bool = False,
156
159
  threshold: float = 0.0,
157
160
  loudness_range_target: float = 7.0,
158
161
  keep_loudness_range_target: bool = False,
@@ -212,6 +215,7 @@ class FFmpegNormalize:
212
215
  self.target_level = check_range(target_level, -99, 0, name="target_level")
213
216
 
214
217
  self.print_stats = print_stats
218
+ self.print_summary = print_summary
215
219
 
216
220
  self.threshold = check_range(threshold, 0, 99, name="threshold")
217
221
 
@@ -555,3 +559,97 @@ class FFmpegNormalize:
555
559
  indent=4,
556
560
  )
557
561
  print()
562
+
563
+ if self.print_summary:
564
+ print(self.format_summary(), file=sys.stderr)
565
+
566
+ def get_summary(self) -> list[SummaryRow]:
567
+ """
568
+ Return the before/after summary for all audio streams of all files.
569
+
570
+ Each entry contains the input level, output level, output peak and the
571
+ applied gain in dB. The level is the integrated loudness for EBU, the
572
+ RMS level for RMS, and the peak level for peak normalization. The
573
+ output peak is the true peak for EBU and the sample peak otherwise.
574
+ Values that are not available are None.
575
+
576
+ Returns:
577
+ list[SummaryRow]: One summary entry per audio stream
578
+ """
579
+ return list(
580
+ chain.from_iterable(
581
+ media_file.get_summary() for media_file in self.media_files
582
+ )
583
+ )
584
+
585
+ def format_summary(self) -> str:
586
+ """
587
+ Return the before/after summary as a human-readable table.
588
+
589
+ Returns:
590
+ str: The formatted table, including a header line with the target
591
+ """
592
+ if self.normalization_type == "ebu":
593
+ header = (
594
+ f"EBU R128 normalization, target: {self.target_level:.1f} LUFS, "
595
+ f"true peak: {self.true_peak:.1f} dBTP"
596
+ )
597
+ if self.dynamic:
598
+ header += ", dynamic mode"
599
+ columns = ["Input (LUFS)", "Output (LUFS)", "True peak (dBTP)", "Gain (dB)"]
600
+ elif self.normalization_type == "rms":
601
+ header = f"RMS normalization, target: {self.target_level:.1f} dBFS"
602
+ columns = [
603
+ "Input RMS (dBFS)",
604
+ "Output RMS (dBFS)",
605
+ "Peak (dBFS)",
606
+ "Gain (dB)",
607
+ ]
608
+ else:
609
+ header = f"Peak normalization, target: {self.target_level:.1f} dBFS"
610
+ columns = ["Input peak (dBFS)", "Output peak (dBFS)", "Gain (dB)"]
611
+
612
+ if self.batch:
613
+ header += ", batch mode (relative loudness preserved)"
614
+ if self.dry_run:
615
+ header += ", dry run"
616
+
617
+ rows = self.get_summary()
618
+ if not rows:
619
+ return f"Summary ({header}): no files processed"
620
+
621
+ def fmt(value: float | None, signed: bool = False) -> str:
622
+ if value is None:
623
+ return "-"
624
+ return f"{value:+.1f}" if signed else f"{value:.1f}"
625
+
626
+ table: list[list[str]] = []
627
+ for row in rows:
628
+ values = [fmt(row["input_level"]), fmt(row["output_level"])]
629
+ if self.normalization_type != "peak":
630
+ values.append(fmt(row["output_peak"]))
631
+ values.append(fmt(row["gain"], signed=True))
632
+ table.append(
633
+ [row["input_file"], str(row["stream_id"]), *values, row["status"]]
634
+ )
635
+
636
+ headings = ["File", "Stream", *columns, "Status"]
637
+ widths = [
638
+ max(len(headings[i]), *(len(line[i]) for line in table))
639
+ for i in range(len(headings))
640
+ ]
641
+
642
+ def fmt_line(cells: list[str]) -> str:
643
+ # Left-align the file name and status, right-align everything else
644
+ parts = [
645
+ cell.ljust(widths[i])
646
+ if i in (0, len(cells) - 1)
647
+ else cell.rjust(widths[i])
648
+ for i, cell in enumerate(cells)
649
+ ]
650
+ return " ".join(parts).rstrip()
651
+
652
+ lines = [f"Summary ({header}):", "", fmt_line(headings)]
653
+ lines.append(fmt_line(["-" * w for w in widths]))
654
+ lines.extend(fmt_line(line) for line in table)
655
+ 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
 
@@ -811,6 +812,7 @@ class MediaFile:
811
812
  f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
812
813
  )
813
814
  normalization_filter = "acopy"
815
+ audio_stream.applied_gain = 0.0
814
816
  else:
815
817
  if self.ffmpeg_normalize.normalization_type == "ebu":
816
818
  normalization_filter = audio_stream.get_second_pass_opts_ebu(
@@ -1223,3 +1225,23 @@ class MediaFile:
1223
1225
  return (
1224
1226
  audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
1225
1227
  )
1228
+
1229
+ def get_summary(self) -> list[SummaryRow]:
1230
+ """
1231
+ Return the before/after summary for each analyzed audio stream.
1232
+
1233
+ Streams without any measurement (e.g. streams not selected for
1234
+ normalization) are left out, unless the file failed to process.
1235
+
1236
+ Returns:
1237
+ list[SummaryRow]: One summary entry per audio stream
1238
+ """
1239
+ rows = [
1240
+ audio_stream.get_summary()
1241
+ for audio_stream in self.streams["audio"].values()
1242
+ ]
1243
+ return [
1244
+ row
1245
+ for row in rows
1246
+ if row["status"] == "error" or row["input_level"] is not None
1247
+ ]
@@ -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(