ffmpeg-normalize 1.32.1__py2.py3-none-any.whl → 1.32.2__py2.py3-none-any.whl
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/_media_file.py +82 -36
- ffmpeg_normalize/_streams.py +9 -2
- ffmpeg_normalize/_version.py +1 -1
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/METADATA +8 -1
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/RECORD +9 -9
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/LICENSE +0 -0
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/WHEEL +0 -0
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/entry_points.txt +0 -0
- {ffmpeg_normalize-1.32.1.dist-info → ffmpeg_normalize-1.32.2.dist-info}/top_level.txt +0 -0
ffmpeg_normalize/_media_file.py
CHANGED
|
@@ -6,7 +6,7 @@ import re
|
|
|
6
6
|
import shlex
|
|
7
7
|
from shutil import move, rmtree
|
|
8
8
|
from tempfile import mkdtemp
|
|
9
|
-
from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict
|
|
9
|
+
from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict, Union
|
|
10
10
|
|
|
11
11
|
from mutagen.id3 import ID3, TXXX
|
|
12
12
|
from mutagen.mp3 import MP3
|
|
@@ -75,10 +75,17 @@ class MediaFile:
|
|
|
75
75
|
current_ext = os.path.splitext(output_file)[1][1:]
|
|
76
76
|
# we need to check if it's empty, e.g. /dev/null or NUL
|
|
77
77
|
if current_ext == "" or self.output_file == os.devnull:
|
|
78
|
+
_logger.debug(
|
|
79
|
+
f"Current extension is unset, or output file is a null device, using extension: {self.ffmpeg_normalize.extension}"
|
|
80
|
+
)
|
|
78
81
|
self.output_ext = self.ffmpeg_normalize.extension
|
|
79
82
|
else:
|
|
83
|
+
_logger.debug(
|
|
84
|
+
f"Current extension is set from output file, using extension: {current_ext}"
|
|
85
|
+
)
|
|
80
86
|
self.output_ext = current_ext
|
|
81
87
|
self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
|
|
88
|
+
self.temp_file: Union[str, None] = None
|
|
82
89
|
|
|
83
90
|
self.parse_streams()
|
|
84
91
|
|
|
@@ -203,12 +210,17 @@ class MediaFile:
|
|
|
203
210
|
# run the first pass to get loudness stats
|
|
204
211
|
self._first_pass()
|
|
205
212
|
|
|
206
|
-
#
|
|
213
|
+
# for second pass, create a temp file
|
|
214
|
+
temp_dir = mkdtemp()
|
|
215
|
+
self.temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
|
|
216
|
+
|
|
207
217
|
if self.ffmpeg_normalize.replaygain:
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
_logger.debug(
|
|
219
|
+
"ReplayGain mode: Second pass will run with temporary file to get stats."
|
|
220
|
+
)
|
|
221
|
+
self.output_file = self.temp_file
|
|
210
222
|
|
|
211
|
-
# run the second pass as a whole
|
|
223
|
+
# run the second pass as a whole.
|
|
212
224
|
if self.ffmpeg_normalize.progress:
|
|
213
225
|
with tqdm(
|
|
214
226
|
total=100,
|
|
@@ -222,7 +234,20 @@ class MediaFile:
|
|
|
222
234
|
for _ in self._second_pass():
|
|
223
235
|
pass
|
|
224
236
|
|
|
225
|
-
|
|
237
|
+
# remove temp dir; this will remove the temp file as well if it has not been renamed (e.g. for replaygain)
|
|
238
|
+
if os.path.exists(temp_dir):
|
|
239
|
+
rmtree(temp_dir, ignore_errors=True)
|
|
240
|
+
|
|
241
|
+
# This will use stats from ebu_pass2 if available (from the main second pass),
|
|
242
|
+
# or fall back to ebu_pass1.
|
|
243
|
+
if self.ffmpeg_normalize.replaygain:
|
|
244
|
+
_logger.debug(
|
|
245
|
+
"ReplayGain tagging is enabled. Proceeding with tag calculation/application."
|
|
246
|
+
)
|
|
247
|
+
self._run_replaygain()
|
|
248
|
+
|
|
249
|
+
if not self.ffmpeg_normalize.replaygain:
|
|
250
|
+
_logger.info(f"Normalized file written to {self.output_file}")
|
|
226
251
|
|
|
227
252
|
def _run_replaygain(self) -> None:
|
|
228
253
|
"""
|
|
@@ -233,13 +258,32 @@ class MediaFile:
|
|
|
233
258
|
# get the audio streams
|
|
234
259
|
audio_streams = list(self.streams["audio"].values())
|
|
235
260
|
|
|
236
|
-
#
|
|
237
|
-
|
|
261
|
+
# Attempt to use EBU pass 2 statistics, which account for pre-filters.
|
|
262
|
+
# These are populated by the main second pass if it runs (not a dry run)
|
|
263
|
+
# and normalization_type is 'ebu'.
|
|
264
|
+
loudness_stats_source = "ebu_pass2"
|
|
265
|
+
loudnorm_stats = audio_streams[0].loudness_statistics.get("ebu_pass2")
|
|
266
|
+
|
|
267
|
+
if loudnorm_stats is None:
|
|
268
|
+
_logger.warning(
|
|
269
|
+
"ReplayGain: Second pass EBU statistics (ebu_pass2) not found. "
|
|
270
|
+
"Falling back to first pass EBU statistics (ebu_pass1). "
|
|
271
|
+
"This may not account for pre-filters if any are used."
|
|
272
|
+
)
|
|
273
|
+
loudness_stats_source = "ebu_pass1"
|
|
274
|
+
loudnorm_stats = audio_streams[0].loudness_statistics.get("ebu_pass1")
|
|
238
275
|
|
|
239
276
|
if loudnorm_stats is None:
|
|
240
|
-
_logger.error(
|
|
277
|
+
_logger.error(
|
|
278
|
+
f"ReplayGain: No loudness statistics available from {loudness_stats_source} (and fallback) for stream 0. "
|
|
279
|
+
"Cannot calculate ReplayGain tags."
|
|
280
|
+
)
|
|
241
281
|
return
|
|
242
282
|
|
|
283
|
+
_logger.debug(
|
|
284
|
+
f"Using statistics from {loudness_stats_source} for ReplayGain calculation."
|
|
285
|
+
)
|
|
286
|
+
|
|
243
287
|
# apply the replaygain tag from the first audio stream (to all audio streams)
|
|
244
288
|
if len(audio_streams) > 1:
|
|
245
289
|
_logger.warning(
|
|
@@ -249,23 +293,31 @@ class MediaFile:
|
|
|
249
293
|
)
|
|
250
294
|
|
|
251
295
|
target_level = self.ffmpeg_normalize.target_level
|
|
252
|
-
|
|
253
|
-
|
|
296
|
+
# Use 'input_i' and 'input_tp' from the chosen stats.
|
|
297
|
+
# For ebu_pass2, these are measurements *after* pre-filter but *before* loudnorm adjustment.
|
|
298
|
+
input_i = loudnorm_stats.get("input_i")
|
|
299
|
+
input_tp = loudnorm_stats.get("input_tp")
|
|
254
300
|
|
|
255
301
|
if input_i is None or input_tp is None:
|
|
256
|
-
_logger.error(
|
|
302
|
+
_logger.error(
|
|
303
|
+
f"ReplayGain: 'input_i' or 'input_tp' missing from {loudness_stats_source} statistics. "
|
|
304
|
+
"Cannot calculate ReplayGain tags."
|
|
305
|
+
)
|
|
257
306
|
return
|
|
258
307
|
|
|
259
308
|
track_gain = -(input_i - target_level) # dB
|
|
260
309
|
track_peak = 10 ** (input_tp / 20) # linear scale
|
|
261
310
|
|
|
262
|
-
_logger.debug(f"Track gain: {track_gain} dB")
|
|
263
|
-
_logger.debug(f"Track peak: {track_peak}")
|
|
311
|
+
_logger.debug(f"Calculated Track gain: {track_gain:.2f} dB")
|
|
312
|
+
_logger.debug(f"Calculated Track peak: {track_peak:.2f}")
|
|
264
313
|
|
|
265
|
-
if not self.ffmpeg_normalize.dry_run:
|
|
314
|
+
if not self.ffmpeg_normalize.dry_run: # This uses the overall dry_run state
|
|
266
315
|
self._write_replaygain_tags(track_gain, track_peak)
|
|
267
316
|
else:
|
|
268
|
-
_logger.warning(
|
|
317
|
+
_logger.warning(
|
|
318
|
+
"Overall dry_run is enabled, not actually writing ReplayGain tags to the file. "
|
|
319
|
+
"Tag calculation based on available stats was performed."
|
|
320
|
+
)
|
|
269
321
|
|
|
270
322
|
def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
|
|
271
323
|
"""
|
|
@@ -554,33 +606,27 @@ class MediaFile:
|
|
|
554
606
|
|
|
555
607
|
cmd_runner = CommandRunner()
|
|
556
608
|
try:
|
|
557
|
-
|
|
558
|
-
yield from cmd_runner.run_ffmpeg_command(cmd)
|
|
559
|
-
except Exception as e:
|
|
560
|
-
_logger.error(
|
|
561
|
-
f"Error while running command {shlex.join(cmd)}! Error: {e}"
|
|
562
|
-
)
|
|
563
|
-
raise e
|
|
564
|
-
else:
|
|
565
|
-
if self.output_file != os.devnull:
|
|
566
|
-
_logger.debug(
|
|
567
|
-
f"Moving temporary file from {temp_file} to {self.output_file}"
|
|
568
|
-
)
|
|
569
|
-
move(temp_file, self.output_file)
|
|
570
|
-
rmtree(temp_dir, ignore_errors=True)
|
|
609
|
+
yield from cmd_runner.run_ffmpeg_command(cmd)
|
|
571
610
|
except Exception as e:
|
|
572
|
-
|
|
573
|
-
rmtree(temp_dir, ignore_errors=True)
|
|
611
|
+
_logger.error(f"Error while running command {shlex.join(cmd)}! Error: {e}")
|
|
574
612
|
raise e
|
|
613
|
+
else:
|
|
614
|
+
# only move the temp file if it's not a null device and ReplayGain is not enabled!
|
|
615
|
+
if self.output_file != os.devnull and not self.ffmpeg_normalize.replaygain:
|
|
616
|
+
_logger.debug(
|
|
617
|
+
f"Moving temporary file from {temp_file} to {self.output_file}"
|
|
618
|
+
)
|
|
619
|
+
move(temp_file, self.output_file)
|
|
575
620
|
|
|
576
621
|
output = cmd_runner.get_output()
|
|
577
622
|
# in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
|
|
578
623
|
# overall output (which includes multiple loudnorm stats)
|
|
579
624
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
625
|
+
ebu_pass_2_stats = list(
|
|
626
|
+
AudioStream.prune_and_parse_loudnorm_output(output).values()
|
|
627
|
+
)
|
|
628
|
+
for idx, audio_stream in enumerate(self.streams["audio"].values()):
|
|
629
|
+
audio_stream.set_second_pass_stats(ebu_pass_2_stats[idx])
|
|
584
630
|
|
|
585
631
|
# warn if self.media_file.ffmpeg_normalize.dynamic == False and any of the second pass stats contain "normalization_type" == "dynamic"
|
|
586
632
|
if self.ffmpeg_normalize.dynamic is False:
|
ffmpeg_normalize/_streams.py
CHANGED
|
@@ -65,6 +65,9 @@ class MediaStream:
|
|
|
65
65
|
self.media_file = media_file
|
|
66
66
|
self.stream_type = stream_type
|
|
67
67
|
self.stream_id = stream_id
|
|
68
|
+
_logger.debug(
|
|
69
|
+
f"Created MediaStream for {self.media_file.input_file}, {self.stream_type} stream {self.stream_id}"
|
|
70
|
+
)
|
|
68
71
|
|
|
69
72
|
def __repr__(self) -> str:
|
|
70
73
|
return (
|
|
@@ -175,6 +178,9 @@ class AudioStream(MediaStream):
|
|
|
175
178
|
Args:
|
|
176
179
|
stats (dict): The EBU loudness statistics.
|
|
177
180
|
"""
|
|
181
|
+
_logger.debug(
|
|
182
|
+
f"Setting second pass stats for stream {self.stream_id} from {stats}"
|
|
183
|
+
)
|
|
178
184
|
self.loudness_statistics["ebu_pass2"] = stats
|
|
179
185
|
|
|
180
186
|
def get_pcm_codec(self) -> str:
|
|
@@ -339,8 +345,9 @@ class AudioStream(MediaStream):
|
|
|
339
345
|
output (str): The output from ffmpeg.
|
|
340
346
|
|
|
341
347
|
Returns:
|
|
342
|
-
|
|
348
|
+
dict[int, EbuLoudnessStatistics]: The EBU loudness statistics.
|
|
343
349
|
"""
|
|
350
|
+
_logger.debug("Parsing loudnorm stats from output")
|
|
344
351
|
pruned_output = CommandRunner.prune_ffmpeg_progress_from_output(output)
|
|
345
352
|
output_lines = [line.strip() for line in pruned_output.split("\n")]
|
|
346
353
|
return AudioStream._parse_loudnorm_output(output_lines)
|
|
@@ -359,7 +366,7 @@ class AudioStream(MediaStream):
|
|
|
359
366
|
FFmpegNormalizeError: When the output could not be parsed.
|
|
360
367
|
|
|
361
368
|
Returns:
|
|
362
|
-
EbuLoudnessStatistics:
|
|
369
|
+
dict[int, EbuLoudnessStatistics]: stream index and the EBU loudness statistics, if found.
|
|
363
370
|
"""
|
|
364
371
|
result = dict[int, EbuLoudnessStatistics]()
|
|
365
372
|
stream_index = -1
|
ffmpeg_normalize/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.32.
|
|
1
|
+
__version__ = "1.32.2"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: ffmpeg-normalize
|
|
3
|
-
Version: 1.32.
|
|
3
|
+
Version: 1.32.2
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Home-page: https://github.com/slhck/ffmpeg-normalize
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -119,6 +119,13 @@ The only reason this project exists in its current form is because [@benjaoming]
|
|
|
119
119
|
# Changelog
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
## v1.32.2 (2025-05-08)
|
|
123
|
+
|
|
124
|
+
* Docs: reference changelog.
|
|
125
|
+
|
|
126
|
+
* Fix: make replaygain use second pass stats.
|
|
127
|
+
|
|
128
|
+
|
|
122
129
|
## v1.32.1 (2025-05-08)
|
|
123
130
|
|
|
124
131
|
* Fix missing mutagen requirement, fixes #283.
|
|
@@ -4,13 +4,13 @@ ffmpeg_normalize/_cmd_utils.py,sha256=iGzO3iOylDUOnx-FCKd84BMxiIhmIthxU1tg7kvf4S
|
|
|
4
4
|
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
5
|
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=79wzFR4ZOgy-Wn0ywi8PNJzsrxiDSMKtJ6_auHxiQvo,11762
|
|
6
6
|
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
|
-
ffmpeg_normalize/_media_file.py,sha256=
|
|
8
|
-
ffmpeg_normalize/_streams.py,sha256=
|
|
9
|
-
ffmpeg_normalize/_version.py,sha256=
|
|
7
|
+
ffmpeg_normalize/_media_file.py,sha256=E1PugUYf-SyOAbtPkJ3UHNfrJR3Ed5KaO8OOT9UHh8k,25737
|
|
8
|
+
ffmpeg_normalize/_streams.py,sha256=w-gzAFUbnoLiRABckUgYqdhVgidtEATNd3di-jiP9fU,20599
|
|
9
|
+
ffmpeg_normalize/_version.py,sha256=WdfJcWchCw6txwQmjH_WAwImubvfgUZlGyLcv7_k8f4,23
|
|
10
10
|
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
ffmpeg_normalize-1.32.
|
|
12
|
-
ffmpeg_normalize-1.32.
|
|
13
|
-
ffmpeg_normalize-1.32.
|
|
14
|
-
ffmpeg_normalize-1.32.
|
|
15
|
-
ffmpeg_normalize-1.32.
|
|
16
|
-
ffmpeg_normalize-1.32.
|
|
11
|
+
ffmpeg_normalize-1.32.2.dist-info/LICENSE,sha256=mw5RQE6v4UXG_d2gYIQw9rq6jYWQCtzIs3fSm5sBSrs,1076
|
|
12
|
+
ffmpeg_normalize-1.32.2.dist-info/METADATA,sha256=mZweBzs3rrgkjaVbr3H3jzaU4H3bMUO8MN0COu15ZTM,32758
|
|
13
|
+
ffmpeg_normalize-1.32.2.dist-info/WHEEL,sha256=fS9sRbCBHs7VFcwJLnLXN1MZRR0_TVTxvXKzOnaSFs8,110
|
|
14
|
+
ffmpeg_normalize-1.32.2.dist-info/entry_points.txt,sha256=X0EC5ptb0iGOxrk3Aa65dVQtvUixngLd_2-iAtSixdc,68
|
|
15
|
+
ffmpeg_normalize-1.32.2.dist-info/top_level.txt,sha256=wnUkr17ckPrrU1JsxZQiXbEBUnHKsC64yck-MemEBuI,17
|
|
16
|
+
ffmpeg_normalize-1.32.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|