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.
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/PKG-INFO +2 -1
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/README.md +1 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/pyproject.toml +1 -1
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/pyproject.toml.orig +1 -1
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__main__.py +47 -10
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py +175 -10
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_media_file.py +26 -1
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_presets.py +6 -45
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_streams.py +90 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
{ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
for stream in
|
|
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
|
-
#
|
|
425
|
-
|
|
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()},
|
|
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
|
-
"""
|
|
174
|
+
"""Apply preset values only to options omitted from the command line.
|
|
177
175
|
|
|
178
176
|
Args:
|
|
179
|
-
preset_data:
|
|
180
|
-
cli_args: Parsed
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.42.1 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|