ffmpeg-normalize 1.37.7__tar.gz → 1.38.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 (17) hide show
  1. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/PKG-INFO +5 -4
  2. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/README.md +3 -1
  3. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/pyproject.toml +3 -4
  4. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/__main__.py +4 -3
  5. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_media_file.py +39 -26
  6. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_streams.py +27 -0
  7. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/LICENSE.md +0 -0
  8. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/__init__.py +0 -0
  9. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
  10. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_errors.py +0 -0
  11. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +0 -0
  12. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_logger.py +0 -0
  13. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_presets.py +0 -0
  14. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  15. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  16. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  17. {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.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.37.7
3
+ Version: 1.38.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -14,7 +14,6 @@ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
14
  Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
15
15
  Classifier: Natural Language :: English
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
@@ -25,7 +24,7 @@ Requires-Dist: colorama>=0.4.6 ; sys_platform == 'win32'
25
24
  Requires-Dist: ffmpeg-progress-yield>=1.0.1
26
25
  Requires-Dist: colorlog==6.7.0
27
26
  Requires-Dist: mutagen>=1.47.0
28
- Requires-Python: >=3.9
27
+ Requires-Python: >=3.10
29
28
  Project-URL: Homepage, https://github.com/slhck/ffmpeg-normalize
30
29
  Project-URL: Repository, https://github.com/slhck/ffmpeg-normalize
31
30
  Description-Content-Type: text/markdown
@@ -58,13 +57,15 @@ This program normalizes media files to a certain loudness level using the EBU R1
58
57
 
59
58
  ## 🚀 Quick Start
60
59
 
61
- 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
60
+ 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html) and Python 3.10 or higher
62
61
  2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
63
62
  3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
64
63
 
65
64
 
66
65
  ## 🆕 What's New
67
66
 
67
+ - Version 1.38.0 writes the normalized output directly to the destination without using temporary files
68
+
68
69
  - Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
69
70
 
70
71
  Example:
@@ -26,13 +26,15 @@ This program normalizes media files to a certain loudness level using the EBU R1
26
26
 
27
27
  ## 🚀 Quick Start
28
28
 
29
- 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html)
29
+ 1. Install a recent version of [ffmpeg](https://ffmpeg.org/download.html) and Python 3.10 or higher
30
30
  2. Run `pip3 install ffmpeg-normalize` and `ffmpeg-normalize /path/to/your/file.mp4`, alternatively install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) and run `uvx ffmpeg-normalize /path/to/your/file.mp4`
31
31
  3. Done! 🎧 (the normalized file will be called `normalized/file.mkv`)
32
32
 
33
33
 
34
34
  ## 🆕 What's New
35
35
 
36
+ - Version 1.38.0 writes the normalized output directly to the destination without using temporary files
37
+
36
38
  - Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
37
39
 
38
40
  Example:
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.37.7"
7
+ version = "1.38.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,14 +21,13 @@ classifiers = [
21
21
  "Topic :: Multimedia :: Sound/Audio :: Conversion",
22
22
  "Natural Language :: English",
23
23
  "Programming Language :: Python :: 3",
24
- "Programming Language :: Python :: 3.9",
25
24
  "Programming Language :: Python :: 3.10",
26
25
  "Programming Language :: Python :: 3.11",
27
26
  "Programming Language :: Python :: 3.12",
28
27
  "Programming Language :: Python :: 3.13",
29
28
  "Programming Language :: Python :: 3.14",
30
29
  ]
31
- requires-python = ">=3.9"
30
+ requires-python = ">=3.10"
32
31
  dependencies = [
33
32
  "tqdm>=4.64.1",
34
33
  "colorama>=0.4.6; platform_system=='Windows'",
@@ -50,7 +49,7 @@ package-data = {"ffmpeg_normalize" = ["data/**/*.json"]}
50
49
 
51
50
  [dependency-groups]
52
51
  dev = [
53
- "pytest>=8.1.1,<9",
52
+ "pytest>=9.0.3",
54
53
  "ruff>=0.12.11",
55
54
  "types-tqdm",
56
55
  "shtab>=1.7.0",
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+
5
+ # Import version from package
6
+ import importlib.metadata
4
7
  import json
5
8
  import logging
6
9
  import os
@@ -15,9 +18,6 @@ from ._ffmpeg_normalize import NORMALIZATION_TYPES, FFmpegNormalize
15
18
  from ._logger import setup_cli_logger
16
19
  from ._presets import PresetManager
17
20
 
18
- # Import version from package
19
- import importlib.metadata
20
-
21
21
  __version__ = importlib.metadata.version("ffmpeg-normalize")
22
22
 
23
23
  _logger = logging.getLogger(__name__)
@@ -42,6 +42,7 @@ def create_parser() -> argparse.ArgumentParser:
42
42
  - `TMP` / `TEMP` / `TMPDIR`
43
43
  Sets the path to the temporary directory in which files are
44
44
  stored before being moved to the final output directory.
45
+ Only valid for ReplayGain and when the input file == output file.
45
46
  Note: You need to use full paths.
46
47
 
47
48
  - `FFMPEG_PATH`
@@ -4,8 +4,8 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import shlex
7
- from shutil import move, rmtree
8
- from tempfile import mkdtemp
7
+ from shutil import rmtree
8
+ from tempfile import mkdtemp, mkstemp
9
9
  from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict, Union
10
10
 
11
11
  from mutagen.id3 import ID3, TXXX
@@ -297,14 +297,14 @@ class MediaFile:
297
297
  f"Batch mode: Skipping first pass (already completed), using batch reference = {batch_reference:.2f}"
298
298
  )
299
299
 
300
- # for second pass, create a temp file
301
- temp_dir = mkdtemp()
302
- self.temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
300
+ temp_dir = None
303
301
 
304
302
  if self.ffmpeg_normalize.replaygain:
305
303
  _logger.debug(
306
304
  "ReplayGain mode: Second pass will run with temporary file to get stats."
307
305
  )
306
+ temp_dir = mkdtemp()
307
+ self.temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
308
308
  self.output_file = self.temp_file
309
309
 
310
310
  # run the second pass as a whole.
@@ -322,7 +322,7 @@ class MediaFile:
322
322
  pass
323
323
 
324
324
  # remove temp dir; this will remove the temp file as well if it has not been renamed (e.g. for replaygain)
325
- if os.path.exists(temp_dir):
325
+ if temp_dir and os.path.exists(temp_dir):
326
326
  rmtree(temp_dir, ignore_errors=True)
327
327
 
328
328
  # This will use stats from ebu_pass2 if available (from the main second pass),
@@ -660,6 +660,14 @@ class MediaFile:
660
660
  if self.ffmpeg_normalize.pre_filter:
661
661
  filter_chain.append(self.ffmpeg_normalize.pre_filter)
662
662
 
663
+ # Apply channel conversion before normalization so that the
664
+ # normalization filter operates on the same channel layout that
665
+ # was measured in the first pass. See issue #316.
666
+ if self.ffmpeg_normalize.audio_channels:
667
+ filter_chain.append(
668
+ f"aformat=sample_fmts=fltp:channel_layouts={self.ffmpeg_normalize.audio_channels}c"
669
+ )
670
+
663
671
  filter_chain.append(normalization_filter)
664
672
 
665
673
  if self.ffmpeg_normalize.post_filter:
@@ -828,39 +836,44 @@ class MediaFile:
828
836
  yield 100
829
837
  return
830
838
 
831
- # track temp_dir for cleanup
832
- temp_dir = None
833
- temp_file = None
839
+ is_in_place_overwrite = os.path.realpath(self.input_file) == os.path.realpath(
840
+ self.output_file
841
+ )
834
842
 
835
- # special case: if output is a null device, write directly to it
836
- if self.output_file == os.devnull:
837
- cmd.append(self.output_file)
838
- else:
839
- temp_dir = mkdtemp()
840
- temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
843
+ temp_file: Union[str, None] = None
844
+ if is_in_place_overwrite:
845
+ # need to create a temporary file because we cannot override
846
+ # the same input file
847
+ output_dir = os.path.dirname(self.output_file) or "."
848
+ fd, temp_file = mkstemp(
849
+ suffix=f".{self.output_ext}",
850
+ prefix=f".{os.path.splitext(os.path.basename(self.output_file))[0]}.",
851
+ dir=output_dir,
852
+ )
853
+ os.close(fd)
854
+ _logger.debug(
855
+ f"Output file is the same as the input file, "
856
+ f"encoding to temporary file {temp_file} first"
857
+ )
841
858
  cmd.append(temp_file)
859
+ else:
860
+ cmd.append(self.output_file)
842
861
 
843
862
  cmd_runner = CommandRunner()
844
863
  try:
845
864
  yield from cmd_runner.run_ffmpeg_command(cmd)
846
865
  except Exception as e:
847
866
  _logger.error(f"Error while running command {shlex.join(cmd)}! Error: {e}")
867
+ if temp_file and os.path.exists(temp_file):
868
+ os.remove(temp_file)
848
869
  raise e
849
870
  else:
850
- # only move the temp file if it's not a null device and ReplayGain is not enabled!
851
- if (
852
- self.output_file != os.devnull
853
- and temp_file
854
- and not self.ffmpeg_normalize.replaygain
855
- ):
871
+ # for in-place normalization, move the finished temp file over the input
872
+ if temp_file:
856
873
  _logger.debug(
857
874
  f"Moving temporary file from {temp_file} to {self.output_file}"
858
875
  )
859
- move(temp_file, self.output_file)
860
- finally:
861
- # clean up temp directory if it was created
862
- if temp_dir and os.path.exists(temp_dir):
863
- rmtree(temp_dir, ignore_errors=True)
876
+ os.replace(temp_file, self.output_file)
864
877
 
865
878
  output = cmd_runner.get_output()
866
879
  # in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
@@ -210,6 +210,10 @@ class AudioStream(MediaStream):
210
210
  Get a filter string for current_filter, with the pre-filter
211
211
  added before. Applies the input label before.
212
212
 
213
+ If a target channel count is set via ``audio_channels``, a channel
214
+ layout conversion is inserted before the analysis/normalization
215
+ filter so that measurements reflect the downmixed signal.
216
+
213
217
  Args:
214
218
  current_filter (str): The current filter.
215
219
 
@@ -220,10 +224,33 @@ class AudioStream(MediaStream):
220
224
  filter_chain = []
221
225
  if self.media_file.ffmpeg_normalize.pre_filter:
222
226
  filter_chain.append(self.media_file.ffmpeg_normalize.pre_filter)
227
+ channel_conversion = self._get_channel_conversion_filter()
228
+ if channel_conversion:
229
+ filter_chain.append(channel_conversion)
223
230
  filter_chain.append(current_filter)
224
231
  filter_str = input_label + ",".join(filter_chain)
225
232
  return filter_str
226
233
 
234
+ def _get_channel_conversion_filter(self) -> str | None:
235
+ """
236
+ Return an ``aformat`` filter string that downmixes/upmixes to the
237
+ requested channel count, or None if no conversion is configured.
238
+
239
+ The ``Nc`` channel-layout notation matches ffmpeg's default layout
240
+ selection for ``-ac N``, so the analysis pass measures the same
241
+ signal that the output will contain.
242
+
243
+ A float planar sample format is requested so the downmix is not
244
+ attenuated to fit an integer range. This is what the analysis
245
+ measurement should reflect, since the volume gain is applied in
246
+ the filter graph (where samples are also float) before being
247
+ written to the output codec.
248
+ """
249
+ audio_channels = self.media_file.ffmpeg_normalize.audio_channels
250
+ if not audio_channels:
251
+ return None
252
+ return f"aformat=sample_fmts=fltp:channel_layouts={audio_channels}c"
253
+
227
254
  def parse_astats(self) -> Iterator[float]:
228
255
  """
229
256
  Use ffmpeg with astats filter to get the mean (RMS) and max (peak) volume of the input file.