ffmpeg-normalize 1.43.0__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.43.0 → ffmpeg_normalize-1.43.1}/PKG-INFO +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/pyproject.toml +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/pyproject.toml.orig +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__main__.py +29 -10
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py +76 -9
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_media_file.py +4 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_presets.py +6 -45
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/README.md +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_streams.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/py.typed +0 -0
|
@@ -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
|
),
|
|
@@ -243,8 +246,9 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
243
246
|
level. However, a group of music files all from the same album is generally meant to be
|
|
244
247
|
listened to at the relative volumes they were recorded at. In batch mode, all the specified
|
|
245
248
|
files are considered to be part of a single album and their relative volumes are preserved.
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
248
252
|
|
|
249
253
|
Batch mode works with all normalization types (EBU, RMS, peak).
|
|
250
254
|
"""
|
|
@@ -672,11 +676,16 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
672
676
|
),
|
|
673
677
|
default=FFmpegNormalize.DEFAULTS["extension"],
|
|
674
678
|
)
|
|
679
|
+
if suppress_defaults:
|
|
680
|
+
for action in parser._actions:
|
|
681
|
+
action.default = argparse.SUPPRESS
|
|
675
682
|
return parser
|
|
676
683
|
|
|
677
684
|
|
|
678
685
|
def main() -> None:
|
|
679
|
-
|
|
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))
|
|
680
689
|
setup_cli_logger(arguments=cli_args)
|
|
681
690
|
|
|
682
691
|
def error(message: object) -> NoReturn:
|
|
@@ -707,7 +716,9 @@ def main() -> None:
|
|
|
707
716
|
try:
|
|
708
717
|
preset_data = preset_manager.load_preset(cli_args.preset)
|
|
709
718
|
_logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
|
|
710
|
-
preset_manager.merge_preset_with_args(
|
|
719
|
+
preset_manager.merge_preset_with_args(
|
|
720
|
+
preset_data, cli_args, set(vars(explicit_args))
|
|
721
|
+
)
|
|
711
722
|
_logger.info(f"Applied preset '{cli_args.preset}'")
|
|
712
723
|
except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
|
|
713
724
|
error(str(e))
|
|
@@ -825,6 +836,7 @@ def main() -> None:
|
|
|
825
836
|
"Please fix the issues above and try again."
|
|
826
837
|
)
|
|
827
838
|
|
|
839
|
+
output_files: list[str] = []
|
|
828
840
|
for index, input_file in enumerate(input_files):
|
|
829
841
|
if cli_args.output is not None and index < len(cli_args.output):
|
|
830
842
|
if cli_args.output_folder and cli_args.output_folder != "normalized":
|
|
@@ -843,12 +855,15 @@ def main() -> None:
|
|
|
843
855
|
+ "."
|
|
844
856
|
+ cli_args.extension,
|
|
845
857
|
)
|
|
846
|
-
|
|
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)
|
|
858
|
+
output_files.append(output_file)
|
|
851
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):
|
|
852
867
|
if (
|
|
853
868
|
os.path.exists(output_file)
|
|
854
869
|
and not cli_args.force
|
|
@@ -867,6 +882,10 @@ def main() -> None:
|
|
|
867
882
|
_logger.warning(f"Input file '{input_file}' is not a file, skipping")
|
|
868
883
|
continue
|
|
869
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
|
+
|
|
870
889
|
try:
|
|
871
890
|
ffmpeg_normalize.add_media_file(input_file, output_file)
|
|
872
891
|
except FFmpegNormalizeError as e:
|
{ffmpeg_normalize-1.43.0 → 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
|
|
@@ -385,23 +386,68 @@ class FFmpegNormalize:
|
|
|
385
386
|
errors.append(error_msg)
|
|
386
387
|
return errors
|
|
387
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
|
+
|
|
388
428
|
def _calculate_batch_reference(self) -> float | None:
|
|
389
429
|
"""
|
|
390
|
-
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.
|
|
391
434
|
|
|
392
435
|
Returns:
|
|
393
436
|
float | None: The batch reference loudness value, or None if no measurements found.
|
|
394
437
|
|
|
395
438
|
Note:
|
|
396
|
-
|
|
397
|
-
use quietest/loudest track, etc.)
|
|
439
|
+
RMS and EBU averages weight each selected stream equally.
|
|
398
440
|
"""
|
|
399
441
|
measurements: list[float] = []
|
|
442
|
+
peaks: list[float] = []
|
|
400
443
|
|
|
401
444
|
for media_file in self.media_files:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
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))
|
|
405
451
|
if self.normalization_type == "ebu":
|
|
406
452
|
# Get EBU integrated loudness from first pass
|
|
407
453
|
ebu_stats = stream.loudness_statistics.get("ebu_pass1")
|
|
@@ -418,6 +464,7 @@ class FFmpegNormalize:
|
|
|
418
464
|
if max_val is not None:
|
|
419
465
|
measurements.append(float(max_val))
|
|
420
466
|
|
|
467
|
+
measurements = [value for value in measurements if math.isfinite(value)]
|
|
421
468
|
if not measurements:
|
|
422
469
|
_logger.warning(
|
|
423
470
|
"No loudness measurements found for batch reference calculation. "
|
|
@@ -425,12 +472,27 @@ class FFmpegNormalize:
|
|
|
425
472
|
)
|
|
426
473
|
return None
|
|
427
474
|
|
|
428
|
-
#
|
|
429
|
-
|
|
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)
|
|
430
492
|
_logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
|
|
431
493
|
_logger.info(
|
|
432
494
|
f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
|
|
433
|
-
f"({self.normalization_type.upper()},
|
|
495
|
+
f"({self.normalization_type.upper()}, based on {len(measurements)} stream(s))"
|
|
434
496
|
)
|
|
435
497
|
|
|
436
498
|
return batch_reference
|
|
@@ -446,6 +508,11 @@ class FFmpegNormalize:
|
|
|
446
508
|
In non-batch mode, each file is processed completely (both passes) before
|
|
447
509
|
moving to the next file.
|
|
448
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
|
+
)
|
|
449
516
|
if self.batch:
|
|
450
517
|
# Batch mode: analyze all files first, then normalize with relative adjustments
|
|
451
518
|
_logger.info(
|
|
@@ -784,7 +784,10 @@ class MediaFile:
|
|
|
784
784
|
list[str]: the filters, in application order
|
|
785
785
|
"""
|
|
786
786
|
skip_normalization = False
|
|
787
|
-
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
|
+
):
|
|
788
791
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
789
792
|
if (
|
|
790
793
|
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
|
-
"""
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|