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.
@@ -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
- # shortcut to apply replaygain
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
- self._run_replaygain()
209
- return
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
- _logger.info(f"Normalized file written to {self.output_file}")
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
- # get the loudnorm stats from the first pass
237
- loudnorm_stats = audio_streams[0].loudness_statistics["ebu_pass1"]
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("no loudnorm stats available in first pass stats!")
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
- input_i = loudnorm_stats["input_i"] # Integrated loudness
253
- input_tp = loudnorm_stats["input_tp"] # True peak
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("no input_i or input_tp available in first pass stats!")
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("Dry run used, not actually writing replaygain tags")
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
- try:
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
- if self.output_file != os.devnull:
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
- all_stats = AudioStream.prune_and_parse_loudnorm_output(output)
581
- for stream_id, audio_stream in self.streams["audio"].items():
582
- if stream_id in all_stats:
583
- audio_stream.set_second_pass_stats(all_stats[stream_id])
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:
@@ -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
- list: The EBU loudness statistics.
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: The EBU loudness statistics, if found.
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
@@ -1 +1 @@
1
- __version__ = "1.32.1"
1
+ __version__ = "1.32.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ffmpeg-normalize
3
- Version: 1.32.1
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=aQcVWTMwbTtb23yAHyBsbnDx4R5l3_bsPflhkYxKTEA,23354
8
- ffmpeg_normalize/_streams.py,sha256=C0b8GT4EK0W02x8pM99H1-BjjO-pLxYFNvZ4R7gu4CM,20223
9
- ffmpeg_normalize/_version.py,sha256=IiCFW8nXLS3OVDl5yCPVb9Rfxn41n84KaeKd1Mtuxls,23
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.1.dist-info/LICENSE,sha256=mw5RQE6v4UXG_d2gYIQw9rq6jYWQCtzIs3fSm5sBSrs,1076
12
- ffmpeg_normalize-1.32.1.dist-info/METADATA,sha256=LXVL1zdmfILSOHbJSnvh5GY7J8DJHjBPbBfPUBrb4_M,32655
13
- ffmpeg_normalize-1.32.1.dist-info/WHEEL,sha256=fS9sRbCBHs7VFcwJLnLXN1MZRR0_TVTxvXKzOnaSFs8,110
14
- ffmpeg_normalize-1.32.1.dist-info/entry_points.txt,sha256=X0EC5ptb0iGOxrk3Aa65dVQtvUixngLd_2-iAtSixdc,68
15
- ffmpeg_normalize-1.32.1.dist-info/top_level.txt,sha256=wnUkr17ckPrrU1JsxZQiXbEBUnHKsC64yck-MemEBuI,17
16
- ffmpeg_normalize-1.32.1.dist-info/RECORD,,
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,,