ffmpeg-normalize 1.32.5__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.
@@ -0,0 +1,665 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ import shlex
7
+ from shutil import move, rmtree
8
+ from tempfile import mkdtemp
9
+ from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict, Union
10
+
11
+ from mutagen.id3 import ID3, TXXX
12
+ from mutagen.mp3 import MP3
13
+ from mutagen.mp4 import MP4
14
+ from mutagen.oggopus import OggOpus
15
+ from mutagen.oggvorbis import OggVorbis
16
+ from tqdm import tqdm
17
+
18
+ from ._cmd_utils import DUR_REGEX, CommandRunner
19
+ from ._errors import FFmpegNormalizeError
20
+ from ._streams import (
21
+ AudioStream,
22
+ LoudnessStatisticsWithMetadata,
23
+ SubtitleStream,
24
+ VideoStream,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from ffmpeg_normalize import FFmpegNormalize
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ # Note: this does not contain MP3, see https://github.com/slhck/ffmpeg-normalize/issues/246
33
+ # We may need to remove other formats as well, to be checked.
34
+ AUDIO_ONLY_FORMATS = {"aac", "ast", "flac", "mka", "oga", "ogg", "opus", "wav"}
35
+ ONE_STREAM = {"aac", "ast", "flac", "mp3", "wav"}
36
+
37
+ TQDM_BAR_FORMAT = "{desc}: {percentage:3.2f}% |{bar}{r_bar}"
38
+
39
+
40
+ def _to_ms(**kwargs: str) -> int:
41
+ hour = int(kwargs.get("hour", 0))
42
+ minute = int(kwargs.get("min", 0))
43
+ sec = int(kwargs.get("sec", 0))
44
+ ms = int(kwargs.get("ms", 0))
45
+
46
+ return (hour * 60 * 60 * 1000) + (minute * 60 * 1000) + (sec * 1000) + ms
47
+
48
+
49
+ class StreamDict(TypedDict):
50
+ audio: dict[int, AudioStream]
51
+ video: dict[int, VideoStream]
52
+ subtitle: dict[int, SubtitleStream]
53
+
54
+
55
+ class MediaFile:
56
+ """
57
+ Class that holds a file, its streams and adjustments
58
+ """
59
+
60
+ def __init__(
61
+ self, ffmpeg_normalize: FFmpegNormalize, input_file: str, output_file: str
62
+ ):
63
+ """
64
+ Initialize a media file for later normalization by parsing the streams.
65
+
66
+ Args:
67
+ ffmpeg_normalize (FFmpegNormalize): reference to overall settings
68
+ input_file (str): Path to input file
69
+ output_file (str): Path to output file
70
+ """
71
+ self.ffmpeg_normalize = ffmpeg_normalize
72
+ self.skip = False
73
+ self.input_file = input_file
74
+ self.output_file = output_file
75
+ current_ext = os.path.splitext(output_file)[1][1:]
76
+ # we need to check if it's empty, e.g. /dev/null or NUL
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
+ )
81
+ self.output_ext = self.ffmpeg_normalize.extension
82
+ else:
83
+ _logger.debug(
84
+ f"Current extension is set from output file, using extension: {current_ext}"
85
+ )
86
+ self.output_ext = current_ext
87
+ self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
88
+ self.temp_file: Union[str, None] = None
89
+
90
+ self.parse_streams()
91
+
92
+ def _stream_ids(self) -> list[int]:
93
+ """
94
+ Get all stream IDs of this file.
95
+
96
+ Returns:
97
+ list: List of stream IDs
98
+ """
99
+ return (
100
+ list(self.streams["audio"].keys())
101
+ + list(self.streams["video"].keys())
102
+ + list(self.streams["subtitle"].keys())
103
+ )
104
+
105
+ def __repr__(self) -> str:
106
+ return os.path.basename(self.input_file)
107
+
108
+ def parse_streams(self) -> None:
109
+ """
110
+ Try to parse all input streams from file and set them in self.streams.
111
+
112
+ Raises:
113
+ FFmpegNormalizeError: If no audio streams are found
114
+ """
115
+ _logger.debug(f"Parsing streams of {self.input_file}")
116
+
117
+ cmd = [
118
+ self.ffmpeg_normalize.ffmpeg_exe,
119
+ "-i",
120
+ self.input_file,
121
+ "-c",
122
+ "copy",
123
+ "-t",
124
+ "0",
125
+ "-map",
126
+ "0",
127
+ "-f",
128
+ "null",
129
+ os.devnull,
130
+ ]
131
+
132
+ output = CommandRunner().run_command(cmd).get_output()
133
+
134
+ _logger.debug("Stream parsing command output:")
135
+ _logger.debug(output)
136
+
137
+ output_lines = [line.strip() for line in output.split("\n")]
138
+
139
+ duration = None
140
+ for line in output_lines:
141
+ if "Duration" in line:
142
+ if duration_search := DUR_REGEX.search(line):
143
+ duration = _to_ms(**duration_search.groupdict()) / 1000
144
+ _logger.debug(f"Found duration: {duration} s")
145
+ else:
146
+ _logger.warning("Could not extract duration from input file!")
147
+
148
+ if not line.startswith("Stream"):
149
+ continue
150
+
151
+ if stream_id_match := re.search(r"#0:([\d]+)", line):
152
+ stream_id = int(stream_id_match.group(1))
153
+ if stream_id in self._stream_ids():
154
+ continue
155
+ else:
156
+ continue
157
+
158
+ if "Audio" in line:
159
+ _logger.debug(f"Found audio stream at index {stream_id}")
160
+ sample_rate_match = re.search(r"(\d+) Hz", line)
161
+ sample_rate = (
162
+ int(sample_rate_match.group(1)) if sample_rate_match else None
163
+ )
164
+ bit_depth_match = re.search(r"[sfu](\d+)(p|le|be)?", line)
165
+ bit_depth = int(bit_depth_match.group(1)) if bit_depth_match else None
166
+ self.streams["audio"][stream_id] = AudioStream(
167
+ self.ffmpeg_normalize,
168
+ self,
169
+ stream_id,
170
+ sample_rate,
171
+ bit_depth,
172
+ duration,
173
+ )
174
+
175
+ elif "Video" in line:
176
+ _logger.debug(f"Found video stream at index {stream_id}")
177
+ self.streams["video"][stream_id] = VideoStream(
178
+ self.ffmpeg_normalize, self, stream_id
179
+ )
180
+
181
+ elif "Subtitle" in line:
182
+ _logger.debug(f"Found subtitle stream at index {stream_id}")
183
+ self.streams["subtitle"][stream_id] = SubtitleStream(
184
+ self.ffmpeg_normalize, self, stream_id
185
+ )
186
+
187
+ if not self.streams["audio"]:
188
+ raise FFmpegNormalizeError(
189
+ f"Input file {self.input_file} does not contain any audio streams"
190
+ )
191
+
192
+ if (
193
+ self.output_ext.lower() in ONE_STREAM
194
+ and len(self.streams["audio"].values()) > 1
195
+ ):
196
+ _logger.warning(
197
+ "Output file only supports one stream. Keeping only first audio stream."
198
+ )
199
+ first_stream = list(self.streams["audio"].values())[0]
200
+ self.streams["audio"] = {first_stream.stream_id: first_stream}
201
+ self.streams["video"] = {}
202
+ self.streams["subtitle"] = {}
203
+
204
+ def run_normalization(self) -> None:
205
+ """
206
+ Run the normalization process for this file.
207
+ """
208
+ _logger.debug(f"Running normalization for {self.input_file}")
209
+
210
+ # run the first pass to get loudness stats, unless in dynamic EBU mode
211
+ if not (
212
+ self.ffmpeg_normalize.dynamic
213
+ and self.ffmpeg_normalize.normalization_type == "ebu"
214
+ ):
215
+ self._first_pass()
216
+ else:
217
+ _logger.debug(
218
+ "Dynamic EBU mode: First pass will not run, as it is not needed."
219
+ )
220
+
221
+ # for second pass, create a temp file
222
+ temp_dir = mkdtemp()
223
+ self.temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
224
+
225
+ if self.ffmpeg_normalize.replaygain:
226
+ _logger.debug(
227
+ "ReplayGain mode: Second pass will run with temporary file to get stats."
228
+ )
229
+ self.output_file = self.temp_file
230
+
231
+ # run the second pass as a whole.
232
+ if self.ffmpeg_normalize.progress:
233
+ with tqdm(
234
+ total=100,
235
+ position=1,
236
+ desc="Second Pass",
237
+ bar_format=TQDM_BAR_FORMAT,
238
+ ) as pbar:
239
+ for progress in self._second_pass():
240
+ pbar.update(progress - pbar.n)
241
+ else:
242
+ for _ in self._second_pass():
243
+ pass
244
+
245
+ # remove temp dir; this will remove the temp file as well if it has not been renamed (e.g. for replaygain)
246
+ if os.path.exists(temp_dir):
247
+ rmtree(temp_dir, ignore_errors=True)
248
+
249
+ # This will use stats from ebu_pass2 if available (from the main second pass),
250
+ # or fall back to ebu_pass1.
251
+ if self.ffmpeg_normalize.replaygain:
252
+ _logger.debug(
253
+ "ReplayGain tagging is enabled. Proceeding with tag calculation/application."
254
+ )
255
+ self._run_replaygain()
256
+
257
+ if not self.ffmpeg_normalize.replaygain:
258
+ _logger.info(f"Normalized file written to {self.output_file}")
259
+
260
+ def _run_replaygain(self) -> None:
261
+ """
262
+ Run the replaygain process for this file.
263
+ """
264
+ _logger.debug(f"Running replaygain for {self.input_file}")
265
+
266
+ # get the audio streams
267
+ audio_streams = list(self.streams["audio"].values())
268
+
269
+ # Attempt to use EBU pass 2 statistics, which account for pre-filters.
270
+ # These are populated by the main second pass if it runs (not a dry run)
271
+ # and normalization_type is 'ebu'.
272
+ loudness_stats_source = "ebu_pass2"
273
+ loudnorm_stats = audio_streams[0].loudness_statistics.get("ebu_pass2")
274
+
275
+ if loudnorm_stats is None:
276
+ _logger.warning(
277
+ "ReplayGain: Second pass EBU statistics (ebu_pass2) not found. "
278
+ "Falling back to first pass EBU statistics (ebu_pass1). "
279
+ "This may not account for pre-filters if any are used."
280
+ )
281
+ loudness_stats_source = "ebu_pass1"
282
+ loudnorm_stats = audio_streams[0].loudness_statistics.get("ebu_pass1")
283
+
284
+ if loudnorm_stats is None:
285
+ _logger.error(
286
+ f"ReplayGain: No loudness statistics available from {loudness_stats_source} (and fallback) for stream 0. "
287
+ "Cannot calculate ReplayGain tags."
288
+ )
289
+ return
290
+
291
+ _logger.debug(
292
+ f"Using statistics from {loudness_stats_source} for ReplayGain calculation."
293
+ )
294
+
295
+ # apply the replaygain tag from the first audio stream (to all audio streams)
296
+ if len(audio_streams) > 1:
297
+ _logger.warning(
298
+ f"Your input file has {len(audio_streams)} audio streams. "
299
+ "Only the first audio stream's replaygain tag will be applied. "
300
+ "All audio streams will receive the same tag."
301
+ )
302
+
303
+ target_level = self.ffmpeg_normalize.target_level
304
+ # Use 'input_i' and 'input_tp' from the chosen stats.
305
+ # For ebu_pass2, these are measurements *after* pre-filter but *before* loudnorm adjustment.
306
+ input_i = loudnorm_stats.get("input_i")
307
+ input_tp = loudnorm_stats.get("input_tp")
308
+
309
+ if input_i is None or input_tp is None:
310
+ _logger.error(
311
+ f"ReplayGain: 'input_i' or 'input_tp' missing from {loudness_stats_source} statistics. "
312
+ "Cannot calculate ReplayGain tags."
313
+ )
314
+ return
315
+
316
+ track_gain = -(input_i - target_level) # dB
317
+ track_peak = 10 ** (input_tp / 20) # linear scale
318
+
319
+ _logger.debug(f"Calculated Track gain: {track_gain:.2f} dB")
320
+ _logger.debug(f"Calculated Track peak: {track_peak:.2f}")
321
+
322
+ if not self.ffmpeg_normalize.dry_run: # This uses the overall dry_run state
323
+ self._write_replaygain_tags(track_gain, track_peak)
324
+ else:
325
+ _logger.warning(
326
+ "Overall dry_run is enabled, not actually writing ReplayGain tags to the file. "
327
+ "Tag calculation based on available stats was performed."
328
+ )
329
+
330
+ def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
331
+ """
332
+ Write the replaygain tags to the input file.
333
+
334
+ This is based on the code from bohning/usdb_syncer, licensed under the MIT license.
335
+ See: https://github.com/bohning/usdb_syncer/blob/2fa638c4f487dffe9f5364f91e156ba54cb20233/src/usdb_syncer/resource_dl.py
336
+ """
337
+ _logger.debug(f"Writing ReplayGain tags to {self.input_file}")
338
+
339
+ input_file_ext = os.path.splitext(self.input_file)[1]
340
+ if input_file_ext == ".mp3":
341
+ mp3 = MP3(self.input_file, ID3=ID3)
342
+ if not mp3.tags:
343
+ return
344
+ mp3.tags.add(
345
+ TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=[f"{track_gain:.2f} dB"])
346
+ )
347
+ mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_PEAK", text=[f"{track_peak:.6f}"]))
348
+ mp3.save()
349
+ elif input_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
350
+ mp4 = MP4(self.input_file)
351
+ if not mp4.tags:
352
+ mp4.add_tags()
353
+ if not mp4.tags:
354
+ return
355
+ mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = [
356
+ f"{track_gain:.2f} dB".encode()
357
+ ]
358
+ mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = [
359
+ f"{track_peak:.6f}".encode()
360
+ ]
361
+ mp4.save()
362
+ elif input_file_ext == ".ogg":
363
+ ogg = OggVorbis(self.input_file)
364
+ ogg["REPLAYGAIN_TRACK_GAIN"] = [f"{track_gain:.2f} dB"]
365
+ ogg["REPLAYGAIN_TRACK_PEAK"] = [f"{track_peak:.6f}"]
366
+ ogg.save()
367
+ elif input_file_ext == ".opus":
368
+ opus = OggOpus(self.input_file)
369
+ # See https://datatracker.ietf.org/doc/html/rfc7845#section-5.2.1
370
+ opus["R128_TRACK_GAIN"] = [str(round(256 * track_gain))]
371
+ opus.save()
372
+ else:
373
+ _logger.error(
374
+ f"Unsupported input file extension: {input_file_ext} for writing replaygain tags. "
375
+ "Only .mp3, .mp4/.m4a, .ogg, .opus are supported. "
376
+ "If you think this should support more formats, please let me know at "
377
+ "https://github.com/slhck/ffmpeg-normalize/issues"
378
+ )
379
+ return
380
+
381
+ _logger.info(
382
+ f"Successfully wrote replaygain tags to input file {self.input_file}"
383
+ )
384
+
385
+ def _can_write_output_video(self) -> bool:
386
+ """
387
+ Determine whether the output file can contain video at all.
388
+
389
+ Returns:
390
+ bool: True if the output file can contain video, False otherwise
391
+ """
392
+ if self.output_ext.lower() in AUDIO_ONLY_FORMATS:
393
+ return False
394
+
395
+ return not self.ffmpeg_normalize.video_disable
396
+
397
+ def _first_pass(self) -> None:
398
+ """
399
+ Run the first pass of the normalization process.
400
+ """
401
+ _logger.debug(f"Parsing normalization info for {self.input_file}")
402
+
403
+ for index, audio_stream in enumerate(self.streams["audio"].values()):
404
+ if self.ffmpeg_normalize.normalization_type == "ebu":
405
+ fun = getattr(audio_stream, "parse_loudnorm_stats")
406
+ else:
407
+ fun = getattr(audio_stream, "parse_astats")
408
+
409
+ if self.ffmpeg_normalize.progress:
410
+ with tqdm(
411
+ total=100,
412
+ position=1,
413
+ desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
414
+ bar_format=TQDM_BAR_FORMAT,
415
+ ) as pbar:
416
+ for progress in fun():
417
+ pbar.update(progress - pbar.n)
418
+ else:
419
+ for _ in fun():
420
+ pass
421
+
422
+ def _get_audio_filter_cmd(self) -> tuple[str, list[str]]:
423
+ """
424
+ Return the audio filter command and output labels needed.
425
+
426
+ Returns:
427
+ tuple[str, list[str]]: filter_complex command and the required output labels
428
+ """
429
+ filter_chains = []
430
+ output_labels = []
431
+
432
+ for audio_stream in self.streams["audio"].values():
433
+ skip_normalization = False
434
+ if self.ffmpeg_normalize.lower_only:
435
+ if self.ffmpeg_normalize.normalization_type == "ebu":
436
+ if (
437
+ audio_stream.loudness_statistics["ebu_pass1"] is not None
438
+ and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
439
+ < self.ffmpeg_normalize.target_level
440
+ ):
441
+ skip_normalization = True
442
+ elif self.ffmpeg_normalize.normalization_type == "peak":
443
+ if (
444
+ audio_stream.loudness_statistics["max"] is not None
445
+ and audio_stream.loudness_statistics["max"]
446
+ < self.ffmpeg_normalize.target_level
447
+ ):
448
+ skip_normalization = True
449
+ elif self.ffmpeg_normalize.normalization_type == "rms":
450
+ if (
451
+ audio_stream.loudness_statistics["mean"] is not None
452
+ and audio_stream.loudness_statistics["mean"]
453
+ < self.ffmpeg_normalize.target_level
454
+ ):
455
+ skip_normalization = True
456
+
457
+ if skip_normalization:
458
+ _logger.warning(
459
+ f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
460
+ )
461
+ normalization_filter = "acopy"
462
+ else:
463
+ if self.ffmpeg_normalize.normalization_type == "ebu":
464
+ normalization_filter = audio_stream.get_second_pass_opts_ebu()
465
+ else:
466
+ normalization_filter = audio_stream.get_second_pass_opts_peakrms()
467
+
468
+ input_label = f"[0:{audio_stream.stream_id}]"
469
+ output_label = f"[norm{audio_stream.stream_id}]"
470
+ output_labels.append(output_label)
471
+
472
+ filter_chain = []
473
+
474
+ if self.ffmpeg_normalize.pre_filter:
475
+ filter_chain.append(self.ffmpeg_normalize.pre_filter)
476
+
477
+ filter_chain.append(normalization_filter)
478
+
479
+ if self.ffmpeg_normalize.post_filter:
480
+ filter_chain.append(self.ffmpeg_normalize.post_filter)
481
+
482
+ filter_chains.append(input_label + ",".join(filter_chain) + output_label)
483
+
484
+ filter_complex_cmd = ";".join(filter_chains)
485
+
486
+ return filter_complex_cmd, output_labels
487
+
488
+ def _second_pass(self) -> Iterator[float]:
489
+ """
490
+ Construct the second pass command and run it.
491
+
492
+ FIXME: make this method simpler
493
+ """
494
+ _logger.info(f"Running second pass for {self.input_file}")
495
+
496
+ # get the target output stream types depending on the options
497
+ output_stream_types: list[Literal["audio", "video", "subtitle"]] = ["audio"]
498
+ if self._can_write_output_video():
499
+ output_stream_types.append("video")
500
+ if not self.ffmpeg_normalize.subtitle_disable:
501
+ output_stream_types.append("subtitle")
502
+
503
+ # base command, here we will add all other options
504
+ cmd = [self.ffmpeg_normalize.ffmpeg_exe, "-hide_banner", "-y"]
505
+
506
+ # extra options (if any)
507
+ if self.ffmpeg_normalize.extra_input_options:
508
+ cmd.extend(self.ffmpeg_normalize.extra_input_options)
509
+
510
+ # get complex filter command
511
+ audio_filter_cmd, output_labels = self._get_audio_filter_cmd()
512
+
513
+ # add input file and basic filter
514
+ cmd.extend(["-i", self.input_file, "-filter_complex", audio_filter_cmd])
515
+
516
+ # map metadata, only if needed
517
+ if self.ffmpeg_normalize.metadata_disable:
518
+ cmd.extend(["-map_metadata", "-1"])
519
+ else:
520
+ # map global metadata
521
+ cmd.extend(["-map_metadata", "0"])
522
+ # map per-stream metadata (e.g. language tags)
523
+ for stream_type in output_stream_types:
524
+ stream_key = stream_type[0]
525
+ if stream_type not in self.streams:
526
+ continue
527
+ for idx, _ in enumerate(self.streams[stream_type].items()):
528
+ cmd.extend(
529
+ [
530
+ f"-map_metadata:s:{stream_key}:{idx}",
531
+ f"0:s:{stream_key}:{idx}",
532
+ ]
533
+ )
534
+
535
+ # map chapters if needed
536
+ if self.ffmpeg_normalize.chapters_disable:
537
+ cmd.extend(["-map_chapters", "-1"])
538
+ else:
539
+ cmd.extend(["-map_chapters", "0"])
540
+
541
+ # collect all '-map' and codecs needed for output video based on input video
542
+ if self.streams["video"]:
543
+ if self._can_write_output_video():
544
+ for s in self.streams["video"].keys():
545
+ cmd.extend(["-map", f"0:{s}"])
546
+ # set codec (copy by default)
547
+ cmd.extend(["-c:v", self.ffmpeg_normalize.video_codec])
548
+ else:
549
+ if not self.ffmpeg_normalize.video_disable:
550
+ _logger.warning(
551
+ f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
552
+ )
553
+
554
+ # ... and map the output of the normalization filters
555
+ for ol in output_labels:
556
+ cmd.extend(["-map", ol])
557
+
558
+ # set audio codec (never copy)
559
+ if self.ffmpeg_normalize.audio_codec:
560
+ cmd.extend(["-c:a", self.ffmpeg_normalize.audio_codec])
561
+ else:
562
+ for index, (_, audio_stream) in enumerate(self.streams["audio"].items()):
563
+ cmd.extend([f"-c:a:{index}", audio_stream.get_pcm_codec()])
564
+
565
+ # other audio options (if any)
566
+ if self.ffmpeg_normalize.audio_bitrate:
567
+ if self.ffmpeg_normalize.audio_codec == "libvorbis":
568
+ # libvorbis takes just a "-b" option, for some reason
569
+ # https://github.com/slhck/ffmpeg-normalize/issues/277
570
+ cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
571
+ else:
572
+ cmd.extend(["-b:a", str(self.ffmpeg_normalize.audio_bitrate)])
573
+ if self.ffmpeg_normalize.sample_rate:
574
+ cmd.extend(["-ar", str(self.ffmpeg_normalize.sample_rate)])
575
+ if self.ffmpeg_normalize.audio_channels:
576
+ cmd.extend(["-ac", str(self.ffmpeg_normalize.audio_channels)])
577
+
578
+ # ... and subtitles
579
+ if not self.ffmpeg_normalize.subtitle_disable:
580
+ for s in self.streams["subtitle"].keys():
581
+ cmd.extend(["-map", f"0:{s}"])
582
+ # copy subtitles
583
+ cmd.extend(["-c:s", "copy"])
584
+
585
+ if self.ffmpeg_normalize.keep_original_audio:
586
+ highest_index = len(self.streams["audio"])
587
+ for index, _ in enumerate(self.streams["audio"].items()):
588
+ cmd.extend(["-map", f"0:a:{index}"])
589
+ cmd.extend([f"-c:a:{highest_index + index}", "copy"])
590
+
591
+ # extra options (if any)
592
+ if self.ffmpeg_normalize.extra_output_options:
593
+ cmd.extend(self.ffmpeg_normalize.extra_output_options)
594
+
595
+ # output format (if any)
596
+ if self.ffmpeg_normalize.output_format:
597
+ cmd.extend(["-f", self.ffmpeg_normalize.output_format])
598
+
599
+ # if dry run, only show sample command
600
+ if self.ffmpeg_normalize.dry_run:
601
+ cmd.append(self.output_file)
602
+ _logger.warning("Dry run used, not actually running second-pass command")
603
+ CommandRunner(dry=True).run_command(cmd)
604
+ yield 100
605
+ return
606
+
607
+ # track temp_dir for cleanup
608
+ temp_dir = None
609
+ temp_file = None
610
+
611
+ # special case: if output is a null device, write directly to it
612
+ if self.output_file == os.devnull:
613
+ cmd.append(self.output_file)
614
+ else:
615
+ temp_dir = mkdtemp()
616
+ temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
617
+ cmd.append(temp_file)
618
+
619
+ cmd_runner = CommandRunner()
620
+ try:
621
+ yield from cmd_runner.run_ffmpeg_command(cmd)
622
+ except Exception as e:
623
+ _logger.error(f"Error while running command {shlex.join(cmd)}! Error: {e}")
624
+ raise e
625
+ else:
626
+ # only move the temp file if it's not a null device and ReplayGain is not enabled!
627
+ if self.output_file != os.devnull and temp_file and not self.ffmpeg_normalize.replaygain:
628
+ _logger.debug(
629
+ f"Moving temporary file from {temp_file} to {self.output_file}"
630
+ )
631
+ move(temp_file, self.output_file)
632
+ finally:
633
+ # clean up temp directory if it was created
634
+ if temp_dir and os.path.exists(temp_dir):
635
+ rmtree(temp_dir, ignore_errors=True)
636
+
637
+ output = cmd_runner.get_output()
638
+ # in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
639
+ # overall output (which includes multiple loudnorm stats)
640
+ if self.ffmpeg_normalize.normalization_type == "ebu":
641
+ ebu_pass_2_stats = list(
642
+ AudioStream.prune_and_parse_loudnorm_output(output).values()
643
+ )
644
+ for idx, audio_stream in enumerate(self.streams["audio"].values()):
645
+ audio_stream.set_second_pass_stats(ebu_pass_2_stats[idx])
646
+
647
+ # warn if self.media_file.ffmpeg_normalize.dynamic == False and any of the second pass stats contain "normalization_type" == "dynamic"
648
+ if self.ffmpeg_normalize.dynamic is False:
649
+ for audio_stream in self.streams["audio"].values():
650
+ pass2_stats = audio_stream.get_stats()["ebu_pass2"]
651
+ if pass2_stats is None:
652
+ continue
653
+ if pass2_stats["normalization_type"] == "dynamic":
654
+ _logger.warning(
655
+ "You specified linear normalization, but the loudnorm filter reverted to dynamic normalization. "
656
+ "This may lead to unexpected results."
657
+ "Consider your input settings, e.g. choose a lower target level or higher target loudness range."
658
+ )
659
+
660
+ _logger.debug("Normalization finished")
661
+
662
+ def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
663
+ return (
664
+ audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
665
+ )