ffmpeg-normalize 1.34.0__py3-none-any.whl → 1.36.0__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/__main__.py +81 -17
- ffmpeg_normalize/_ffmpeg_normalize.py +205 -17
- ffmpeg_normalize/_media_file.py +29 -10
- ffmpeg_normalize/_presets.py +256 -0
- ffmpeg_normalize/_streams.py +46 -2
- ffmpeg_normalize/data/presets/music.json +6 -0
- ffmpeg_normalize/data/presets/podcast.json +7 -0
- ffmpeg_normalize/data/presets/streaming-video.json +7 -0
- {ffmpeg_normalize-1.34.0.dist-info → ffmpeg_normalize-1.36.0.dist-info}/METADATA +53 -7
- ffmpeg_normalize-1.36.0.dist-info/RECORD +18 -0
- ffmpeg_normalize-1.34.0.dist-info/RECORD +0 -14
- {ffmpeg_normalize-1.34.0.dist-info → ffmpeg_normalize-1.36.0.dist-info}/WHEEL +0 -0
- {ffmpeg_normalize-1.34.0.dist-info → ffmpeg_normalize-1.36.0.dist-info}/entry_points.txt +0 -0
- {ffmpeg_normalize-1.34.0.dist-info → ffmpeg_normalize-1.36.0.dist-info}/licenses/LICENSE.md +0 -0
ffmpeg_normalize/__main__.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import NoReturn
|
|
|
13
13
|
from ._errors import FFmpegNormalizeError
|
|
14
14
|
from ._ffmpeg_normalize import NORMALIZATION_TYPES, FFmpegNormalize
|
|
15
15
|
from ._logger import setup_cli_logger
|
|
16
|
+
from ._presets import PresetManager
|
|
16
17
|
|
|
17
18
|
# Import version from package
|
|
18
19
|
import importlib.metadata
|
|
@@ -50,7 +51,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
50
51
|
|
|
51
52
|
Author: Werner Robitza
|
|
52
53
|
License: MIT
|
|
53
|
-
|
|
54
|
+
Website / Issues: https://github.com/slhck/ffmpeg-normalize
|
|
54
55
|
"""
|
|
55
56
|
),
|
|
56
57
|
)
|
|
@@ -90,7 +91,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
90
91
|
name specified.
|
|
91
92
|
"""
|
|
92
93
|
),
|
|
93
|
-
default="
|
|
94
|
+
default=FFmpegNormalize.DEFAULTS["output_folder"],
|
|
94
95
|
)
|
|
95
96
|
|
|
96
97
|
group_general = parser.add_argument_group("General Options")
|
|
@@ -124,6 +125,28 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
124
125
|
version=f"%(prog)s v{__version__}",
|
|
125
126
|
help="Print version and exit",
|
|
126
127
|
)
|
|
128
|
+
group_general.add_argument(
|
|
129
|
+
"--preset",
|
|
130
|
+
type=str,
|
|
131
|
+
help=textwrap.dedent(
|
|
132
|
+
"""\
|
|
133
|
+
Load options from a preset file.
|
|
134
|
+
|
|
135
|
+
Preset files are JSON files located in the presets directory.
|
|
136
|
+
The directory location depends on your OS:
|
|
137
|
+
- Linux/macOS: ~/.config/ffmpeg-normalize/presets/
|
|
138
|
+
- Windows: %%APPDATA%%/ffmpeg-normalize/presets/
|
|
139
|
+
|
|
140
|
+
Use --list-presets to see available presets.
|
|
141
|
+
CLI options specified on the command line take precedence over preset values.
|
|
142
|
+
"""
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
group_general.add_argument(
|
|
146
|
+
"--list-presets",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="List all available presets and exit",
|
|
149
|
+
)
|
|
127
150
|
|
|
128
151
|
group_normalization = parser.add_argument_group("Normalization")
|
|
129
152
|
group_normalization.add_argument(
|
|
@@ -144,15 +167,15 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
144
167
|
Peak normalization brings the signal to the specified peak level.
|
|
145
168
|
"""
|
|
146
169
|
),
|
|
147
|
-
default="
|
|
170
|
+
default=FFmpegNormalize.DEFAULTS["normalization_type"],
|
|
148
171
|
)
|
|
149
172
|
group_normalization.add_argument(
|
|
150
173
|
"-t",
|
|
151
174
|
"--target-level",
|
|
152
175
|
type=float,
|
|
153
176
|
help=textwrap.dedent(
|
|
154
|
-
"""\
|
|
155
|
-
Normalization target level in dB/LUFS (default:
|
|
177
|
+
f"""\
|
|
178
|
+
Normalization target level in dB/LUFS (default: {FFmpegNormalize.DEFAULTS["target_level"]}).
|
|
156
179
|
|
|
157
180
|
For EBU normalization, it corresponds to Integrated Loudness Target
|
|
158
181
|
in LUFS. The range is -70.0 - -5.0.
|
|
@@ -160,7 +183,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
160
183
|
Otherwise, the range is -99 to 0.
|
|
161
184
|
"""
|
|
162
185
|
),
|
|
163
|
-
default
|
|
186
|
+
default=FFmpegNormalize.DEFAULTS["target_level"],
|
|
164
187
|
)
|
|
165
188
|
group_normalization.add_argument(
|
|
166
189
|
"-p",
|
|
@@ -179,6 +202,24 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
179
202
|
"""
|
|
180
203
|
),
|
|
181
204
|
)
|
|
205
|
+
group_normalization.add_argument(
|
|
206
|
+
"--batch",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help=textwrap.dedent(
|
|
209
|
+
"""\
|
|
210
|
+
Preserve relative loudness between files (album mode).
|
|
211
|
+
|
|
212
|
+
When operating on a group of unrelated files, you usually want all of them at the same
|
|
213
|
+
level. However, a group of music files all from the same album is generally meant to be
|
|
214
|
+
listened to at the relative volumes they were recorded at. In batch mode, all the specified
|
|
215
|
+
files are considered to be part of a single album and their relative volumes are preserved.
|
|
216
|
+
This is done by averaging the loudness of all the files, computing a single adjustment from
|
|
217
|
+
that, and applying a relative adjustment to all the files.
|
|
218
|
+
|
|
219
|
+
Batch mode works with all normalization types (EBU, RMS, peak).
|
|
220
|
+
"""
|
|
221
|
+
),
|
|
222
|
+
)
|
|
182
223
|
|
|
183
224
|
# group_normalization.add_argument(
|
|
184
225
|
# '--threshold',
|
|
@@ -197,12 +238,12 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
197
238
|
"--loudness-range-target",
|
|
198
239
|
type=float,
|
|
199
240
|
help=textwrap.dedent(
|
|
200
|
-
"""\
|
|
201
|
-
EBU Loudness Range Target in LUFS (default:
|
|
241
|
+
f"""\
|
|
242
|
+
EBU Loudness Range Target in LUFS (default: {FFmpegNormalize.DEFAULTS["loudness_range_target"]}).
|
|
202
243
|
Range is 1.0 - 50.0.
|
|
203
244
|
"""
|
|
204
245
|
),
|
|
205
|
-
default=
|
|
246
|
+
default=FFmpegNormalize.DEFAULTS["loudness_range_target"],
|
|
206
247
|
)
|
|
207
248
|
|
|
208
249
|
group_ebu.add_argument(
|
|
@@ -231,26 +272,26 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
231
272
|
"--true-peak",
|
|
232
273
|
type=float,
|
|
233
274
|
help=textwrap.dedent(
|
|
234
|
-
"""\
|
|
235
|
-
EBU Maximum True Peak in dBTP (default:
|
|
275
|
+
f"""\
|
|
276
|
+
EBU Maximum True Peak in dBTP (default: {FFmpegNormalize.DEFAULTS["true_peak"]}).
|
|
236
277
|
Range is -9.0 - +0.0.
|
|
237
278
|
"""
|
|
238
279
|
),
|
|
239
|
-
default
|
|
280
|
+
default=FFmpegNormalize.DEFAULTS["true_peak"],
|
|
240
281
|
)
|
|
241
282
|
|
|
242
283
|
group_ebu.add_argument(
|
|
243
284
|
"--offset",
|
|
244
285
|
type=float,
|
|
245
286
|
help=textwrap.dedent(
|
|
246
|
-
"""\
|
|
247
|
-
EBU Offset Gain (default:
|
|
287
|
+
f"""\
|
|
288
|
+
EBU Offset Gain (default: {FFmpegNormalize.DEFAULTS["offset"]}).
|
|
248
289
|
The gain is applied before the true-peak limiter in the first pass only.
|
|
249
290
|
The offset for the second pass will be automatically determined based on the first pass statistics.
|
|
250
291
|
Range is -99.0 - +99.0.
|
|
251
292
|
"""
|
|
252
293
|
),
|
|
253
|
-
default=
|
|
294
|
+
default=FFmpegNormalize.DEFAULTS["offset"],
|
|
254
295
|
)
|
|
255
296
|
|
|
256
297
|
group_ebu.add_argument(
|
|
@@ -453,7 +494,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
453
494
|
Will attempt to copy video codec by default.
|
|
454
495
|
"""
|
|
455
496
|
),
|
|
456
|
-
default="
|
|
497
|
+
default=FFmpegNormalize.DEFAULTS["video_codec"],
|
|
457
498
|
)
|
|
458
499
|
group_vcodec.add_argument(
|
|
459
500
|
"-sn",
|
|
@@ -547,7 +588,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
547
588
|
specified. (Default: `mkv`)
|
|
548
589
|
"""
|
|
549
590
|
),
|
|
550
|
-
default="
|
|
591
|
+
default=FFmpegNormalize.DEFAULTS["extension"],
|
|
551
592
|
)
|
|
552
593
|
return parser
|
|
553
594
|
|
|
@@ -563,6 +604,28 @@ def main() -> None:
|
|
|
563
604
|
_logger.error(message)
|
|
564
605
|
sys.exit(1)
|
|
565
606
|
|
|
607
|
+
# Handle --list-presets
|
|
608
|
+
preset_manager = PresetManager()
|
|
609
|
+
if cli_args.list_presets:
|
|
610
|
+
presets = preset_manager.get_available_presets()
|
|
611
|
+
if presets:
|
|
612
|
+
print("Available presets:")
|
|
613
|
+
for preset in presets:
|
|
614
|
+
print(f" - {preset}")
|
|
615
|
+
else:
|
|
616
|
+
print(f"No presets found in {preset_manager.presets_dir}")
|
|
617
|
+
sys.exit(0)
|
|
618
|
+
|
|
619
|
+
# Load and apply preset if specified
|
|
620
|
+
if cli_args.preset:
|
|
621
|
+
try:
|
|
622
|
+
preset_data = preset_manager.load_preset(cli_args.preset)
|
|
623
|
+
_logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
|
|
624
|
+
preset_manager.merge_preset_with_args(preset_data, cli_args)
|
|
625
|
+
_logger.info(f"Applied preset '{cli_args.preset}'")
|
|
626
|
+
except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
|
|
627
|
+
error(str(e))
|
|
628
|
+
|
|
566
629
|
def _split_options(opts: str) -> list[str]:
|
|
567
630
|
"""
|
|
568
631
|
Parse extra options (input or output) into a list.
|
|
@@ -636,6 +699,7 @@ def main() -> None:
|
|
|
636
699
|
dry_run=cli_args.dry_run,
|
|
637
700
|
progress=cli_args.progress,
|
|
638
701
|
replaygain=cli_args.replaygain,
|
|
702
|
+
batch=cli_args.batch,
|
|
639
703
|
audio_streams=audio_streams,
|
|
640
704
|
audio_default_only=cli_args.audio_default_only,
|
|
641
705
|
keep_other_audio=cli_args.keep_other_audio,
|
|
@@ -84,6 +84,7 @@ class FFmpegNormalize:
|
|
|
84
84
|
debug (bool, optional): Debug. Defaults to False.
|
|
85
85
|
progress (bool, optional): Progress. Defaults to False.
|
|
86
86
|
replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
|
|
87
|
+
batch (bool, optional): Preserve relative loudness between files (album mode). Defaults to False.
|
|
87
88
|
audio_streams (list[int] | None, optional): List of audio stream indices to normalize. Defaults to None (all streams).
|
|
88
89
|
audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
|
|
89
90
|
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
|
|
@@ -92,6 +93,48 @@ class FFmpegNormalize:
|
|
|
92
93
|
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
93
94
|
"""
|
|
94
95
|
|
|
96
|
+
# Default parameter values - single source of truth for all defaults
|
|
97
|
+
# Note: output_folder is a CLI-level option and not passed to FFmpegNormalize.__init__
|
|
98
|
+
DEFAULTS = {
|
|
99
|
+
"normalization_type": "ebu",
|
|
100
|
+
"target_level": -23.0,
|
|
101
|
+
"print_stats": False,
|
|
102
|
+
"loudness_range_target": 7.0,
|
|
103
|
+
"keep_loudness_range_target": False,
|
|
104
|
+
"keep_lra_above_loudness_range_target": False,
|
|
105
|
+
"true_peak": -2.0,
|
|
106
|
+
"offset": 0.0,
|
|
107
|
+
"lower_only": False,
|
|
108
|
+
"auto_lower_loudness_target": False,
|
|
109
|
+
"dual_mono": False,
|
|
110
|
+
"dynamic": False,
|
|
111
|
+
"audio_codec": "pcm_s16le",
|
|
112
|
+
"audio_bitrate": None,
|
|
113
|
+
"sample_rate": None,
|
|
114
|
+
"audio_channels": None,
|
|
115
|
+
"keep_original_audio": False,
|
|
116
|
+
"pre_filter": None,
|
|
117
|
+
"post_filter": None,
|
|
118
|
+
"video_codec": "copy",
|
|
119
|
+
"video_disable": False,
|
|
120
|
+
"subtitle_disable": False,
|
|
121
|
+
"metadata_disable": False,
|
|
122
|
+
"chapters_disable": False,
|
|
123
|
+
"extra_input_options": None,
|
|
124
|
+
"extra_output_options": None,
|
|
125
|
+
"output_format": None,
|
|
126
|
+
"output_folder": "normalized",
|
|
127
|
+
"extension": "mkv",
|
|
128
|
+
"dry_run": False,
|
|
129
|
+
"debug": False,
|
|
130
|
+
"progress": False,
|
|
131
|
+
"replaygain": False,
|
|
132
|
+
"batch": False,
|
|
133
|
+
"audio_streams": None,
|
|
134
|
+
"audio_default_only": False,
|
|
135
|
+
"keep_other_audio": False,
|
|
136
|
+
}
|
|
137
|
+
|
|
95
138
|
def __init__(
|
|
96
139
|
self,
|
|
97
140
|
normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
|
|
@@ -127,6 +170,7 @@ class FFmpegNormalize:
|
|
|
127
170
|
debug: bool = False,
|
|
128
171
|
progress: bool = False,
|
|
129
172
|
replaygain: bool = False,
|
|
173
|
+
batch: bool = False,
|
|
130
174
|
audio_streams: list[int] | None = None,
|
|
131
175
|
audio_default_only: bool = False,
|
|
132
176
|
keep_other_audio: bool = False,
|
|
@@ -212,6 +256,7 @@ class FFmpegNormalize:
|
|
|
212
256
|
self.debug = debug
|
|
213
257
|
self.progress = progress
|
|
214
258
|
self.replaygain = replaygain
|
|
259
|
+
self.batch = batch
|
|
215
260
|
|
|
216
261
|
# Stream selection options
|
|
217
262
|
self.audio_streams = audio_streams
|
|
@@ -272,29 +317,172 @@ class FFmpegNormalize:
|
|
|
272
317
|
self.media_files.append(MediaFile(self, input_file, output_file))
|
|
273
318
|
self.file_count += 1
|
|
274
319
|
|
|
320
|
+
def _calculate_batch_reference(self) -> float | None:
|
|
321
|
+
"""
|
|
322
|
+
Calculate the batch reference loudness by averaging measurements across all files.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
float | None: The batch reference loudness value, or None if no measurements found.
|
|
326
|
+
|
|
327
|
+
Note:
|
|
328
|
+
TODO: Add option to specify different averaging methods (duration-weighted,
|
|
329
|
+
use quietest/loudest track, etc.)
|
|
330
|
+
"""
|
|
331
|
+
measurements: list[float] = []
|
|
332
|
+
|
|
333
|
+
for media_file in self.media_files:
|
|
334
|
+
# Access audio streams from the streams dict
|
|
335
|
+
audio_streams = media_file.streams.get("audio", {})
|
|
336
|
+
for stream in audio_streams.values():
|
|
337
|
+
if self.normalization_type == "ebu":
|
|
338
|
+
# Get EBU integrated loudness from first pass
|
|
339
|
+
ebu_stats = stream.loudness_statistics.get("ebu_pass1")
|
|
340
|
+
if ebu_stats and "input_i" in ebu_stats:
|
|
341
|
+
measurements.append(float(ebu_stats["input_i"]))
|
|
342
|
+
elif self.normalization_type == "rms":
|
|
343
|
+
# Get RMS mean value
|
|
344
|
+
mean = stream.loudness_statistics.get("mean")
|
|
345
|
+
if mean is not None:
|
|
346
|
+
measurements.append(float(mean))
|
|
347
|
+
elif self.normalization_type == "peak":
|
|
348
|
+
# Get peak max value
|
|
349
|
+
max_val = stream.loudness_statistics.get("max")
|
|
350
|
+
if max_val is not None:
|
|
351
|
+
measurements.append(float(max_val))
|
|
352
|
+
|
|
353
|
+
if not measurements:
|
|
354
|
+
_logger.warning(
|
|
355
|
+
"No loudness measurements found for batch reference calculation. "
|
|
356
|
+
"Batch mode will not be applied."
|
|
357
|
+
)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
# Simple average of all measurements
|
|
361
|
+
batch_reference = sum(measurements) / len(measurements)
|
|
362
|
+
_logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
|
|
363
|
+
_logger.info(
|
|
364
|
+
f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
|
|
365
|
+
f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
return batch_reference
|
|
369
|
+
|
|
275
370
|
def run_normalization(self) -> None:
|
|
276
371
|
"""
|
|
277
|
-
Run the normalization procedures
|
|
372
|
+
Run the normalization procedures.
|
|
373
|
+
|
|
374
|
+
In batch mode, all files are analyzed first (first pass), then a batch reference
|
|
375
|
+
loudness is calculated, and finally all files are normalized (second pass) with
|
|
376
|
+
adjustments relative to the batch reference to preserve relative loudness.
|
|
377
|
+
|
|
378
|
+
In non-batch mode, each file is processed completely (both passes) before
|
|
379
|
+
moving to the next file.
|
|
278
380
|
"""
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
):
|
|
381
|
+
if self.batch:
|
|
382
|
+
# Batch mode: analyze all files first, then normalize with relative adjustments
|
|
282
383
|
_logger.info(
|
|
283
|
-
f"
|
|
384
|
+
f"Batch mode enabled: processing {self.file_count} file(s) while preserving relative loudness"
|
|
284
385
|
)
|
|
285
386
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
387
|
+
# Recommend RMS/Peak for album normalization instead of EBU
|
|
388
|
+
if self.normalization_type == "ebu":
|
|
389
|
+
_logger.warning(
|
|
390
|
+
"Using EBU R128 normalization with --batch. For true album normalization where "
|
|
391
|
+
"all tracks are shifted by the same amount, consider using --normalization-type rms "
|
|
392
|
+
"or --normalization-type peak instead. EBU normalization applies different processing "
|
|
393
|
+
"to each track based on its loudness characteristics, which may alter relative levels "
|
|
394
|
+
"slightly due to psychoacoustic adjustments."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Warn if using dynamic EBU mode with batch
|
|
398
|
+
if self.dynamic and self.normalization_type == "ebu":
|
|
399
|
+
_logger.warning(
|
|
400
|
+
"ffmpeg uses dynamic EBU normalization. This may change relative "
|
|
401
|
+
"loudness within a file. Use linear mode for true album normalization, or "
|
|
402
|
+
"switch to --normalization-type peak or --normalization-type rms instead. "
|
|
403
|
+
"To force linear mode, use --keep-lra-above-loudness-range-target or "
|
|
404
|
+
"--keep-loudness-range-target."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Phase 1: Run first pass on all files to collect measurements
|
|
408
|
+
_logger.info("Phase 1: Analyzing all files...")
|
|
409
|
+
for index, media_file in enumerate(
|
|
410
|
+
tqdm(
|
|
411
|
+
self.media_files,
|
|
412
|
+
desc="Analysis",
|
|
413
|
+
disable=not self.progress,
|
|
414
|
+
position=0,
|
|
415
|
+
)
|
|
416
|
+
):
|
|
417
|
+
_logger.info(
|
|
418
|
+
f"Analyzing file {media_file} ({index + 1} of {self.file_count})"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
# Only run first pass if not in dynamic EBU mode
|
|
423
|
+
if not (self.dynamic and self.normalization_type == "ebu"):
|
|
424
|
+
media_file._first_pass()
|
|
425
|
+
else:
|
|
426
|
+
_logger.debug(
|
|
427
|
+
"Dynamic EBU mode: First pass skipped for this file."
|
|
428
|
+
)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
if len(self.media_files) > 1:
|
|
431
|
+
_logger.error(
|
|
432
|
+
f"Error analyzing input file {media_file}, will "
|
|
433
|
+
f"continue batch-processing. Error was: {e}"
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
raise e
|
|
437
|
+
|
|
438
|
+
# Phase 2: Calculate batch reference loudness
|
|
439
|
+
batch_reference = self._calculate_batch_reference()
|
|
440
|
+
|
|
441
|
+
# Phase 3: Run second pass on all files with batch reference
|
|
442
|
+
_logger.info("Phase 2: Normalizing all files...")
|
|
443
|
+
for index, media_file in enumerate(
|
|
444
|
+
tqdm(
|
|
445
|
+
self.media_files,
|
|
446
|
+
desc="Normalization",
|
|
447
|
+
disable=not self.progress,
|
|
448
|
+
position=0,
|
|
449
|
+
)
|
|
450
|
+
):
|
|
451
|
+
_logger.info(
|
|
452
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
media_file.run_normalization(batch_reference=batch_reference)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
if len(self.media_files) > 1:
|
|
459
|
+
_logger.error(
|
|
460
|
+
f"Error processing input file {media_file}, will "
|
|
461
|
+
f"continue batch-processing. Error was: {e}"
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
raise e
|
|
465
|
+
else:
|
|
466
|
+
# Non-batch mode: process each file completely before moving to the next
|
|
467
|
+
for index, media_file in enumerate(
|
|
468
|
+
tqdm(
|
|
469
|
+
self.media_files, desc="File", disable=not self.progress, position=0
|
|
470
|
+
)
|
|
471
|
+
):
|
|
472
|
+
_logger.info(
|
|
473
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
media_file.run_normalization()
|
|
478
|
+
except Exception as e:
|
|
479
|
+
if len(self.media_files) > 1:
|
|
480
|
+
_logger.error(
|
|
481
|
+
f"Error processing input file {media_file}, will "
|
|
482
|
+
f"continue batch-processing. Error was: {e}"
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
raise e
|
|
298
486
|
|
|
299
487
|
if self.print_stats:
|
|
300
488
|
json.dump(
|
ffmpeg_normalize/_media_file.py
CHANGED
|
@@ -86,6 +86,7 @@ class MediaFile:
|
|
|
86
86
|
self.output_ext = current_ext
|
|
87
87
|
self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
|
|
88
88
|
self.temp_file: Union[str, None] = None
|
|
89
|
+
self.batch_reference: float | None = None
|
|
89
90
|
|
|
90
91
|
self.parse_streams()
|
|
91
92
|
|
|
@@ -265,21 +266,35 @@ class MediaFile:
|
|
|
265
266
|
# Normalize all streams (default behavior)
|
|
266
267
|
return all_audio_streams
|
|
267
268
|
|
|
268
|
-
def run_normalization(self) -> None:
|
|
269
|
+
def run_normalization(self, batch_reference: float | None = None) -> None:
|
|
269
270
|
"""
|
|
270
271
|
Run the normalization process for this file.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
275
|
+
If provided, the first pass is skipped (assumed already done) and this
|
|
276
|
+
reference is used to calculate relative adjustments. Defaults to None.
|
|
271
277
|
"""
|
|
272
278
|
_logger.debug(f"Running normalization for {self.input_file}")
|
|
273
279
|
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
+
# Store batch reference for use in second pass
|
|
281
|
+
self.batch_reference = batch_reference
|
|
282
|
+
|
|
283
|
+
# run the first pass to get loudness stats, unless in dynamic EBU mode or batch mode
|
|
284
|
+
# (in batch mode, first pass is already done in FFmpegNormalize.run_normalization)
|
|
285
|
+
if batch_reference is None:
|
|
286
|
+
if not (
|
|
287
|
+
self.ffmpeg_normalize.dynamic
|
|
288
|
+
and self.ffmpeg_normalize.normalization_type == "ebu"
|
|
289
|
+
):
|
|
290
|
+
self._first_pass()
|
|
291
|
+
else:
|
|
292
|
+
_logger.debug(
|
|
293
|
+
"Dynamic EBU mode: First pass will not run, as it is not needed."
|
|
294
|
+
)
|
|
280
295
|
else:
|
|
281
296
|
_logger.debug(
|
|
282
|
-
"
|
|
297
|
+
f"Batch mode: Skipping first pass (already completed), using batch reference = {batch_reference:.2f}"
|
|
283
298
|
)
|
|
284
299
|
|
|
285
300
|
# for second pass, create a temp file
|
|
@@ -529,9 +544,13 @@ class MediaFile:
|
|
|
529
544
|
normalization_filter = "acopy"
|
|
530
545
|
else:
|
|
531
546
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
532
|
-
normalization_filter = audio_stream.get_second_pass_opts_ebu(
|
|
547
|
+
normalization_filter = audio_stream.get_second_pass_opts_ebu(
|
|
548
|
+
batch_reference=self.batch_reference
|
|
549
|
+
)
|
|
533
550
|
else:
|
|
534
|
-
normalization_filter = audio_stream.get_second_pass_opts_peakrms(
|
|
551
|
+
normalization_filter = audio_stream.get_second_pass_opts_peakrms(
|
|
552
|
+
batch_reference=self.batch_reference
|
|
553
|
+
)
|
|
535
554
|
|
|
536
555
|
input_label = f"[0:{audio_stream.stream_id}]"
|
|
537
556
|
output_label = f"[norm{audio_stream.stream_id}]"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Preset management for ffmpeg-normalize."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ._ffmpeg_normalize import FFmpegNormalize
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_config_dir() -> Path:
|
|
18
|
+
"""Get the platform-specific config directory for ffmpeg-normalize.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Path: Configuration directory for presets
|
|
22
|
+
|
|
23
|
+
On Linux/macOS:
|
|
24
|
+
- XDG_CONFIG_HOME/ffmpeg-normalize (if XDG_CONFIG_HOME is set)
|
|
25
|
+
- ~/.config/ffmpeg-normalize (otherwise)
|
|
26
|
+
|
|
27
|
+
On Windows:
|
|
28
|
+
- %APPDATA%/ffmpeg-normalize
|
|
29
|
+
"""
|
|
30
|
+
if os.name == "nt": # Windows
|
|
31
|
+
appdata = os.getenv("APPDATA")
|
|
32
|
+
if appdata:
|
|
33
|
+
config_dir = Path(appdata) / "ffmpeg-normalize"
|
|
34
|
+
else:
|
|
35
|
+
config_dir = Path.home() / "AppData" / "Roaming" / "ffmpeg-normalize"
|
|
36
|
+
else: # Linux/macOS
|
|
37
|
+
xdg_config = os.getenv("XDG_CONFIG_HOME")
|
|
38
|
+
if xdg_config:
|
|
39
|
+
config_dir = Path(xdg_config) / "ffmpeg-normalize"
|
|
40
|
+
else:
|
|
41
|
+
config_dir = Path.home() / ".config" / "ffmpeg-normalize"
|
|
42
|
+
|
|
43
|
+
return config_dir
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_presets_dir() -> Path:
|
|
47
|
+
"""Get the presets directory.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Path: Directory containing preset files
|
|
51
|
+
"""
|
|
52
|
+
return get_config_dir() / "presets"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_default_presets_dir() -> Path:
|
|
56
|
+
"""Get the package default presets directory.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Path: Directory containing default preset files bundled with the package
|
|
60
|
+
"""
|
|
61
|
+
# Get the directory where this module is located
|
|
62
|
+
module_dir = Path(__file__).parent
|
|
63
|
+
return module_dir / "data" / "presets"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PresetManager:
|
|
67
|
+
"""Manages loading and merging of presets with CLI arguments."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
"""Initialize the preset manager."""
|
|
71
|
+
self.presets_dir = get_presets_dir()
|
|
72
|
+
self.default_presets_dir = get_default_presets_dir()
|
|
73
|
+
|
|
74
|
+
def get_available_presets(self) -> list[str]:
|
|
75
|
+
"""Get list of available preset names.
|
|
76
|
+
|
|
77
|
+
Includes both user-installed presets and default presets from the package.
|
|
78
|
+
User presets take precedence if they have the same name.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
list[str]: List of available preset names (without .json extension)
|
|
82
|
+
"""
|
|
83
|
+
presets = set()
|
|
84
|
+
|
|
85
|
+
# Get presets from user config directory
|
|
86
|
+
if self.presets_dir.exists():
|
|
87
|
+
for file in self.presets_dir.glob("*.json"):
|
|
88
|
+
presets.add(file.stem)
|
|
89
|
+
|
|
90
|
+
# Get default presets from package
|
|
91
|
+
if self.default_presets_dir.exists():
|
|
92
|
+
for file in self.default_presets_dir.glob("*.json"):
|
|
93
|
+
presets.add(file.stem)
|
|
94
|
+
|
|
95
|
+
return sorted(presets)
|
|
96
|
+
|
|
97
|
+
def load_preset(self, preset_name: str) -> dict[str, Any]:
|
|
98
|
+
"""Load a preset file by name.
|
|
99
|
+
|
|
100
|
+
Checks user config directory first, then falls back to package defaults.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
preset_name: Name of the preset (without .json extension)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
dict[str, Any]: Preset configuration as a dictionary
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
FileNotFoundError: If preset file doesn't exist
|
|
110
|
+
json.JSONDecodeError: If preset file is invalid JSON
|
|
111
|
+
"""
|
|
112
|
+
# Try user config directory first
|
|
113
|
+
preset_path = self.presets_dir / f"{preset_name}.json"
|
|
114
|
+
|
|
115
|
+
# Fall back to package default presets
|
|
116
|
+
if not preset_path.exists():
|
|
117
|
+
preset_path = self.default_presets_dir / f"{preset_name}.json"
|
|
118
|
+
|
|
119
|
+
if not preset_path.exists():
|
|
120
|
+
raise FileNotFoundError(
|
|
121
|
+
f"Preset '{preset_name}' not found. "
|
|
122
|
+
f"Available presets: {', '.join(self.get_available_presets()) or 'none'}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with open(preset_path, "r") as f:
|
|
127
|
+
preset_data = json.load(f)
|
|
128
|
+
except json.JSONDecodeError as e:
|
|
129
|
+
raise json.JSONDecodeError(
|
|
130
|
+
f"Invalid JSON in preset '{preset_name}': {e.msg}",
|
|
131
|
+
e.doc,
|
|
132
|
+
e.pos,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not isinstance(preset_data, dict):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Preset must be a JSON object, got {type(preset_data).__name__}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return preset_data
|
|
141
|
+
|
|
142
|
+
def merge_preset_with_args(
|
|
143
|
+
self, preset_data: dict[str, Any], cli_args: Any
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Merge preset data with CLI arguments, giving precedence to CLI args.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
preset_data: Dictionary of preset configuration
|
|
149
|
+
cli_args: Parsed CLI arguments (argparse Namespace object)
|
|
150
|
+
|
|
151
|
+
The CLI arguments take precedence over preset values. This function modifies
|
|
152
|
+
cli_args in place by setting attributes that were not explicitly provided
|
|
153
|
+
on the command line.
|
|
154
|
+
"""
|
|
155
|
+
for key, value in preset_data.items():
|
|
156
|
+
# Convert hyphens to underscores to match argparse attribute names
|
|
157
|
+
attr_name = key.replace("-", "_")
|
|
158
|
+
|
|
159
|
+
# Check if this attribute exists in cli_args
|
|
160
|
+
if not hasattr(cli_args, attr_name):
|
|
161
|
+
_logger.warning(
|
|
162
|
+
f"Preset option '{key}' is not a valid ffmpeg-normalize option. Skipping."
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Get the current value
|
|
167
|
+
current_value = getattr(cli_args, attr_name)
|
|
168
|
+
|
|
169
|
+
# Check if this was explicitly set by the user
|
|
170
|
+
# For most types, we can infer this by checking against the defaults:
|
|
171
|
+
# - None values were not explicitly set
|
|
172
|
+
# - Empty lists were not explicitly set
|
|
173
|
+
# - False boolean flags were not explicitly set
|
|
174
|
+
# - Default numeric values (specific to each option) need special handling
|
|
175
|
+
|
|
176
|
+
should_apply = False
|
|
177
|
+
|
|
178
|
+
if isinstance(value, bool):
|
|
179
|
+
# For boolean flags, apply preset only if currently False (not set)
|
|
180
|
+
should_apply = not current_value
|
|
181
|
+
elif isinstance(current_value, list):
|
|
182
|
+
# For lists (like output), apply preset only if empty
|
|
183
|
+
should_apply = not current_value
|
|
184
|
+
elif current_value is None:
|
|
185
|
+
# For None values, always apply preset (not explicitly set)
|
|
186
|
+
should_apply = True
|
|
187
|
+
else:
|
|
188
|
+
# For other values (numbers, strings), check if they match known defaults
|
|
189
|
+
# This is conservative: only override if the value is a known default
|
|
190
|
+
if (
|
|
191
|
+
attr_name in FFmpegNormalize.DEFAULTS
|
|
192
|
+
and current_value == FFmpegNormalize.DEFAULTS[attr_name]
|
|
193
|
+
):
|
|
194
|
+
should_apply = True
|
|
195
|
+
|
|
196
|
+
if should_apply:
|
|
197
|
+
setattr(cli_args, attr_name, value)
|
|
198
|
+
_logger.debug(f"Applied preset option '{key}' = {value}")
|
|
199
|
+
|
|
200
|
+
def validate_preset(self, preset_name: str) -> tuple[bool, str]:
|
|
201
|
+
"""Validate that a preset file exists and is valid.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
preset_name: Name of the preset to validate
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
tuple[bool, str]: (is_valid, message)
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
self.load_preset(preset_name)
|
|
211
|
+
return True, f"Preset '{preset_name}' is valid"
|
|
212
|
+
except FileNotFoundError as e:
|
|
213
|
+
return False, str(e)
|
|
214
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
215
|
+
return False, f"Error loading preset '{preset_name}': {e}"
|
|
216
|
+
|
|
217
|
+
def install_default_presets(self, force: bool = False) -> tuple[bool, str]:
|
|
218
|
+
"""Install default presets to user config directory.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
force: If True, overwrite existing presets
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
tuple[bool, str]: (success, message)
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
# Create presets directory if it doesn't exist
|
|
228
|
+
self.presets_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
|
|
230
|
+
if not self.default_presets_dir.exists():
|
|
231
|
+
return (
|
|
232
|
+
False,
|
|
233
|
+
f"Default presets directory not found at {self.default_presets_dir}",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Copy all default presets
|
|
237
|
+
installed = []
|
|
238
|
+
skipped = []
|
|
239
|
+
|
|
240
|
+
for preset_file in self.default_presets_dir.glob("*.json"):
|
|
241
|
+
dest_file = self.presets_dir / preset_file.name
|
|
242
|
+
|
|
243
|
+
if dest_file.exists() and not force:
|
|
244
|
+
skipped.append(preset_file.name)
|
|
245
|
+
else:
|
|
246
|
+
shutil.copy2(preset_file, dest_file)
|
|
247
|
+
installed.append(preset_file.name)
|
|
248
|
+
|
|
249
|
+
message = f"Installed {len(installed)} preset(s) to {self.presets_dir}"
|
|
250
|
+
if skipped:
|
|
251
|
+
message += f" ({len(skipped)} skipped, use --force to overwrite)"
|
|
252
|
+
|
|
253
|
+
return True, message
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
return False, f"Error installing default presets: {e}"
|
ffmpeg_normalize/_streams.py
CHANGED
|
@@ -444,9 +444,14 @@ class AudioStream(MediaStream):
|
|
|
444
444
|
)
|
|
445
445
|
return result
|
|
446
446
|
|
|
447
|
-
def get_second_pass_opts_ebu(self) -> str:
|
|
447
|
+
def get_second_pass_opts_ebu(self, batch_reference: float | None = None) -> str:
|
|
448
448
|
"""
|
|
449
449
|
Return second pass loudnorm filter options string for ffmpeg
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
453
|
+
When provided, the target level is adjusted to preserve relative loudness.
|
|
454
|
+
Defaults to None.
|
|
450
455
|
"""
|
|
451
456
|
|
|
452
457
|
# In dynamic mode, we can do everything in one pass, and we do not have first pass stats
|
|
@@ -552,6 +557,19 @@ class AudioStream(MediaStream):
|
|
|
552
557
|
|
|
553
558
|
stats = self.loudness_statistics["ebu_pass1"]
|
|
554
559
|
|
|
560
|
+
# Adjust target level for batch mode to preserve relative loudness
|
|
561
|
+
if batch_reference is not None:
|
|
562
|
+
input_i = float(stats["input_i"])
|
|
563
|
+
# Formula: adjusted_target = target_level + (input_i - batch_reference)
|
|
564
|
+
# If track is quieter than average (input_i < batch_ref), offset is negative → quieter target
|
|
565
|
+
# If track is louder than average (input_i > batch_ref), offset is positive → louder target
|
|
566
|
+
adjusted_target = target_level + (input_i - batch_reference)
|
|
567
|
+
_logger.info(
|
|
568
|
+
f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} LUFS "
|
|
569
|
+
f"(input_i={input_i:.2f}, batch_ref={batch_reference:.2f}, offset={input_i - batch_reference:.2f})"
|
|
570
|
+
)
|
|
571
|
+
target_level = adjusted_target
|
|
572
|
+
|
|
555
573
|
opts = {
|
|
556
574
|
"i": target_level,
|
|
557
575
|
"lra": self.media_file.ffmpeg_normalize.loudness_range_target,
|
|
@@ -576,11 +594,16 @@ class AudioStream(MediaStream):
|
|
|
576
594
|
|
|
577
595
|
return "loudnorm=" + dict_to_filter_opts(opts)
|
|
578
596
|
|
|
579
|
-
def get_second_pass_opts_peakrms(self) -> str:
|
|
597
|
+
def get_second_pass_opts_peakrms(self, batch_reference: float | None = None) -> str:
|
|
580
598
|
"""
|
|
581
599
|
Set the adjustment gain based on chosen option and mean/max volume,
|
|
582
600
|
return the matching ffmpeg volume filter.
|
|
583
601
|
|
|
602
|
+
Args:
|
|
603
|
+
batch_reference (float | None, optional): Reference loudness for batch mode.
|
|
604
|
+
When provided, the target level is adjusted to preserve relative loudness.
|
|
605
|
+
Defaults to None.
|
|
606
|
+
|
|
584
607
|
Returns:
|
|
585
608
|
str: ffmpeg volume filter string
|
|
586
609
|
"""
|
|
@@ -595,6 +618,27 @@ class AudioStream(MediaStream):
|
|
|
595
618
|
normalization_type = self.media_file.ffmpeg_normalize.normalization_type
|
|
596
619
|
target_level = self.media_file.ffmpeg_normalize.target_level
|
|
597
620
|
|
|
621
|
+
# Adjust target level for batch mode to preserve relative loudness
|
|
622
|
+
if batch_reference is not None:
|
|
623
|
+
if normalization_type == "peak":
|
|
624
|
+
measured_level = float(self.loudness_statistics["max"])
|
|
625
|
+
elif normalization_type == "rms":
|
|
626
|
+
measured_level = float(self.loudness_statistics["mean"])
|
|
627
|
+
else:
|
|
628
|
+
raise FFmpegNormalizeError(
|
|
629
|
+
"Can only set adjustment for peak and RMS normalization"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Formula: adjusted_target = target_level + (measured_level - batch_reference)
|
|
633
|
+
# If track is quieter than average (measured < batch_ref), offset is negative → quieter target
|
|
634
|
+
# If track is louder than average (measured > batch_ref), offset is positive → louder target
|
|
635
|
+
adjusted_target = target_level + (measured_level - batch_reference)
|
|
636
|
+
_logger.info(
|
|
637
|
+
f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} dB "
|
|
638
|
+
f"(measured={measured_level:.2f}, batch_ref={batch_reference:.2f}, offset={measured_level - batch_reference:.2f})"
|
|
639
|
+
)
|
|
640
|
+
target_level = adjusted_target
|
|
641
|
+
|
|
598
642
|
if normalization_type == "peak":
|
|
599
643
|
adjustment = 0 + target_level - self.loudness_statistics["max"]
|
|
600
644
|
elif normalization_type == "rms":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ffmpeg-normalize
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.36.0
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Keywords: ffmpeg,normalize,audio
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -46,12 +46,58 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
46
46
|
|
|
47
47
|
## ✨ Features
|
|
48
48
|
|
|
49
|
-
- EBU R128 loudness normalization
|
|
50
|
-
- RMS-based normalization
|
|
51
|
-
- Peak normalization
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
49
|
+
- EBU R128 loudness normalization — Two-pass by default, with an option for one-pass dynamic normalization
|
|
50
|
+
- RMS-based normalization — Adjust audio to a specific RMS level
|
|
51
|
+
- Peak normalization — Adjust audio to a specific peak level
|
|
52
|
+
- Selective audio stream normalization — Normalize specific audio streams or only default streams
|
|
53
|
+
- Video file support — Process video files while preserving video streams
|
|
54
|
+
- Docker support — Run via Docker container
|
|
55
|
+
- Python API — Use programmatically in your Python projects
|
|
56
|
+
- Shell completions — Available for bash, zsh, and fish
|
|
57
|
+
- Album Batch normalization – Process files jointy, preserving relative loudness
|
|
58
|
+
|
|
59
|
+
## 🆕 What's New
|
|
60
|
+
|
|
61
|
+
- 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!
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ffmpeg-normalize input.mp3 --preset podcast
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
applies the podcast preset (EBU R128, -16 LUFS) to your file. Learn more in the [presets guide](https://slhck.info/ffmpeg-normalize/usage/presets/).
|
|
70
|
+
|
|
71
|
+
- Version 1.35.0 has **batch/album normalization** with `--batch`. It preserves relative loudness between files! Perfect for music albums where you want to shift all tracks by the same amount.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
ffmpeg-normalize album/*.flac --batch -nt rms -t -20
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
shifts the entire album so the average RMS is -20 dB, preserving the original relative loudness as mastered.
|
|
80
|
+
|
|
81
|
+
- Version 1.34.0 brings **selective audio stream normalization**! You can now:
|
|
82
|
+
|
|
83
|
+
- Normalize specific audio streams with `-as/--audio-streams` (e.g., `-as 1,2` to normalize only streams 1 and 2)
|
|
84
|
+
- Normalize only default audio streams with `--audio-default-only` (useful for files with multiple language tracks)
|
|
85
|
+
- Keep other streams unchanged with `--keep-other-audio` (copy non-selected streams without normalization)
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ffmpeg-normalize input.mkv -as 1 --keep-other-audio
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
normalizes stream 1 and copies all other audio streams unchanged.
|
|
94
|
+
|
|
95
|
+
Other recent additions:
|
|
96
|
+
|
|
97
|
+
- **Shell completions** (v1.31.0) — Tab completion for bash, zsh, and fish shells. See the [installation guide](https://slhck.info/ffmpeg-normalize/getting-started/installation/#shell-completions) for setup instructions.
|
|
98
|
+
- **`--lower-only` option** — Prevent audio from increasing in loudness, only lower it if needed (works with all normalization types).
|
|
99
|
+
|
|
100
|
+
See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
|
|
55
101
|
|
|
56
102
|
## 🚀 Quick Start
|
|
57
103
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
|
|
2
|
+
ffmpeg_normalize/__main__.py,sha256=4LgOHFwZS0lVcdYptNX4CCPDtG16yfMgUBjgom74FWg,25830
|
|
3
|
+
ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
|
|
4
|
+
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
|
+
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=jCiYhXeV3u8e7sJKOOnGYN5X7stcLv9eg_h_pR-1olM,21372
|
|
6
|
+
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
|
+
ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
|
|
8
|
+
ffmpeg_normalize/_presets.py,sha256=AffqFzAHvy0GxCuJl_qd3xknncPJUQx7OXbSfYAw2xg,8852
|
|
9
|
+
ffmpeg_normalize/_streams.py,sha256=4Dnzuunhqz2qsOhlDv0dKML-lLjmPmUmM7M4dpn66Ow,24910
|
|
10
|
+
ffmpeg_normalize/data/presets/music.json,sha256=rKtIKAD7jlMvnGRsx6QAyPHvrGu-Ecq4M5OcZgwNphA,96
|
|
11
|
+
ffmpeg_normalize/data/presets/podcast.json,sha256=spMBZ_tbfU81NF6o7ZUMyQmQ7kPz3jJ1GKH-hcRmt4s,132
|
|
12
|
+
ffmpeg_normalize/data/presets/streaming-video.json,sha256=Uy4QR0kSla4vXEqctZj26-8f_eS4SQxQWQ9zI_x5QBw,132
|
|
13
|
+
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
ffmpeg_normalize-1.36.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
|
|
15
|
+
ffmpeg_normalize-1.36.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
16
|
+
ffmpeg_normalize-1.36.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
|
|
17
|
+
ffmpeg_normalize-1.36.0.dist-info/METADATA,sha256=hXTuiFMbjXW1T2R9n9PPs8n9lF4i3JQfNbIUhPSbRpE,13933
|
|
18
|
+
ffmpeg_normalize-1.36.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
|
|
2
|
-
ffmpeg_normalize/__main__.py,sha256=pn5OePgr7-P5ajO3HxTMe4yQ1NR7wru5mGMHDklkbQI,22901
|
|
3
|
-
ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
|
|
4
|
-
ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
|
|
5
|
-
ffmpeg_normalize/_ffmpeg_normalize.py,sha256=ThIglofVOXxPMZcIrrYQ03HchlIOKmVSn41qILQNgg4,13193
|
|
6
|
-
ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
|
|
7
|
-
ffmpeg_normalize/_media_file.py,sha256=Dz5vBDOelD1-GnlKX6830UbnlwvARFo3O_O-8Zyqlmw,31214
|
|
8
|
-
ffmpeg_normalize/_streams.py,sha256=V5MnTjSnvQa6BNPSoFrUu0zg6mM-b9qaZE0ltGS2FV0,22329
|
|
9
|
-
ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
ffmpeg_normalize-1.34.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
|
|
11
|
-
ffmpeg_normalize-1.34.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
12
|
-
ffmpeg_normalize-1.34.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
|
|
13
|
-
ffmpeg_normalize-1.34.0.dist-info/METADATA,sha256=yoBY7Ig4p2ijd91p1NcT96URVZKZPaDWb0A0XyysIQY,11425
|
|
14
|
-
ffmpeg_normalize-1.34.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|