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.
- ffmpeg_normalize/__init__.py +18 -0
- ffmpeg_normalize/__main__.py +640 -0
- ffmpeg_normalize/_cmd_utils.py +192 -0
- ffmpeg_normalize/_errors.py +2 -0
- ffmpeg_normalize/_ffmpeg_normalize.py +285 -0
- ffmpeg_normalize/_logger.py +72 -0
- ffmpeg_normalize/_media_file.py +665 -0
- ffmpeg_normalize/_streams.py +594 -0
- ffmpeg_normalize/_version.py +1 -0
- ffmpeg_normalize/py.typed +0 -0
- ffmpeg_normalize-1.32.5.dist-info/METADATA +1467 -0
- ffmpeg_normalize-1.32.5.dist-info/RECORD +16 -0
- ffmpeg_normalize-1.32.5.dist-info/WHEEL +5 -0
- ffmpeg_normalize-1.32.5.dist-info/entry_points.txt +2 -0
- ffmpeg_normalize-1.32.5.dist-info/licenses/LICENSE +21 -0
- ffmpeg_normalize-1.32.5.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|