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.
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/PKG-INFO +5 -4
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/README.md +3 -1
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/pyproject.toml +3 -4
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/__main__.py +4 -3
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_media_file.py +39 -26
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_streams.py +27 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_presets.py +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
839
|
+
is_in_place_overwrite = os.path.realpath(self.input_file) == os.path.realpath(
|
|
840
|
+
self.output_file
|
|
841
|
+
)
|
|
834
842
|
|
|
835
|
-
|
|
836
|
-
if
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
temp_file =
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.37.7 → ffmpeg_normalize-1.38.0}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|