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.
Files changed (18) hide show
  1. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/PKG-INFO +1 -1
  2. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/pyproject.toml +1 -1
  3. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/pyproject.toml.orig +1 -1
  4. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__main__.py +29 -10
  5. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py +76 -9
  6. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_media_file.py +4 -1
  7. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_presets.py +6 -45
  8. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/LICENSE.md +0 -0
  9. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/README.md +0 -0
  10. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/__init__.py +0 -0
  11. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
  12. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_errors.py +0 -0
  13. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_logger.py +0 -0
  14. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/_streams.py +0 -0
  15. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.43.1}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.43.0 → 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.43.0
3
+ Version: 1.43.1
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.43.0"
7
+ version = "1.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.43.0"
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
  ),
@@ -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
- This is done by averaging the loudness of all the files, computing a single adjustment from
247
- that, and applying a relative adjustment to all the files.
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
- 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))
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(preset_data, cli_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
- if not os.path.isdir(cli_args.output_folder) and not cli_args.dry_run:
847
- _logger.warning(
848
- f"Output directory '{cli_args.output_folder}' does not exist, will create"
849
- )
850
- os.makedirs(cli_args.output_folder, exist_ok=True)
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:
@@ -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 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.
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
- TODO: Add option to specify different averaging methods (duration-weighted,
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
- # Access audio streams from the streams dict
403
- audio_streams = media_file.streams.get("audio", {})
404
- 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))
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
- # Simple average of all measurements
429
- 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)
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()}, averaged from {len(measurements)} stream(s))"
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
- """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