ffmpeg-normalize 1.34.0__tar.gz → 1.35.0__tar.gz
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-1.34.0 → ffmpeg_normalize-1.35.0}/PKG-INFO +43 -7
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/README.md +42 -6
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/pyproject.toml +2 -1
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/__main__.py +19 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +163 -17
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_media_file.py +29 -10
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_streams.py +46 -2
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_cmd_utils.py +0 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ffmpeg-normalize
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.35.0
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Keywords: ffmpeg,normalize,audio
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -46,12 +46,48 @@ 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.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.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ffmpeg-normalize album/*.flac --batch -nt rms -t -20
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
shifts the entire album so the average RMS is -20 dB, preserving the original relative loudness as mastered.
|
|
70
|
+
|
|
71
|
+
- Version 1.34.0 brings **selective audio stream normalization**! You can now:
|
|
72
|
+
|
|
73
|
+
- Normalize specific audio streams with `-as/--audio-streams` (e.g., `-as 1,2` to normalize only streams 1 and 2)
|
|
74
|
+
- Normalize only default audio streams with `--audio-default-only` (useful for files with multiple language tracks)
|
|
75
|
+
- Keep other streams unchanged with `--keep-other-audio` (copy non-selected streams without normalization)
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ffmpeg-normalize input.mkv -as 1 --keep-other-audio
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
normalizes stream 1 and copies all other audio streams unchanged.
|
|
84
|
+
|
|
85
|
+
Other recent additions:
|
|
86
|
+
|
|
87
|
+
- **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.
|
|
88
|
+
- **`--lower-only` option** — Prevent audio from increasing in loudness, only lower it if needed (works with all normalization types).
|
|
89
|
+
|
|
90
|
+
See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
|
|
55
91
|
|
|
56
92
|
## 🚀 Quick Start
|
|
57
93
|
|
|
@@ -14,12 +14,48 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
14
14
|
|
|
15
15
|
## ✨ Features
|
|
16
16
|
|
|
17
|
-
- EBU R128 loudness normalization
|
|
18
|
-
- RMS-based normalization
|
|
19
|
-
- Peak normalization
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
17
|
+
- EBU R128 loudness normalization — Two-pass by default, with an option for one-pass dynamic normalization
|
|
18
|
+
- RMS-based normalization — Adjust audio to a specific RMS level
|
|
19
|
+
- Peak normalization — Adjust audio to a specific peak level
|
|
20
|
+
- Selective audio stream normalization — Normalize specific audio streams or only default streams
|
|
21
|
+
- Video file support — Process video files while preserving video streams
|
|
22
|
+
- Docker support — Run via Docker container
|
|
23
|
+
- Python API — Use programmatically in your Python projects
|
|
24
|
+
- Shell completions — Available for bash, zsh, and fish
|
|
25
|
+
- Album Batch normalization – Process files jointy, preserving relative loudness
|
|
26
|
+
|
|
27
|
+
## 🆕 What's New
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ffmpeg-normalize album/*.flac --batch -nt rms -t -20
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
shifts the entire album so the average RMS is -20 dB, preserving the original relative loudness as mastered.
|
|
38
|
+
|
|
39
|
+
- Version 1.34.0 brings **selective audio stream normalization**! You can now:
|
|
40
|
+
|
|
41
|
+
- Normalize specific audio streams with `-as/--audio-streams` (e.g., `-as 1,2` to normalize only streams 1 and 2)
|
|
42
|
+
- Normalize only default audio streams with `--audio-default-only` (useful for files with multiple language tracks)
|
|
43
|
+
- Keep other streams unchanged with `--keep-other-audio` (copy non-selected streams without normalization)
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
ffmpeg-normalize input.mkv -as 1 --keep-other-audio
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
normalizes stream 1 and copies all other audio streams unchanged.
|
|
52
|
+
|
|
53
|
+
Other recent additions:
|
|
54
|
+
|
|
55
|
+
- **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.
|
|
56
|
+
- **`--lower-only` option** — Prevent audio from increasing in loudness, only lower it if needed (works with all normalization types).
|
|
57
|
+
|
|
58
|
+
See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
|
|
23
59
|
|
|
24
60
|
## 🚀 Quick Start
|
|
25
61
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ffmpeg-normalize"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.35.0"
|
|
8
8
|
description = "Normalize audio via ffmpeg"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -53,6 +53,7 @@ dev = [
|
|
|
53
53
|
"ruff>=0.12.11",
|
|
54
54
|
"mypy>=1.0.0",
|
|
55
55
|
"types-tqdm",
|
|
56
|
+
"shtab>=1.7.0",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
59
|
[tool.mypy]
|
|
@@ -179,6 +179,24 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
179
179
|
"""
|
|
180
180
|
),
|
|
181
181
|
)
|
|
182
|
+
group_normalization.add_argument(
|
|
183
|
+
"--batch",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help=textwrap.dedent(
|
|
186
|
+
"""\
|
|
187
|
+
Preserve relative loudness between files (album mode).
|
|
188
|
+
|
|
189
|
+
When operating on a group of unrelated files, you usually want all of them at the same
|
|
190
|
+
level. However, a group of music files all from the same album is generally meant to be
|
|
191
|
+
listened to at the relative volumes they were recorded at. In batch mode, all the specified
|
|
192
|
+
files are considered to be part of a single album and their relative volumes are preserved.
|
|
193
|
+
This is done by averaging the loudness of all the files, computing a single adjustment from
|
|
194
|
+
that, and applying a relative adjustment to all the files.
|
|
195
|
+
|
|
196
|
+
Batch mode works with all normalization types (EBU, RMS, peak).
|
|
197
|
+
"""
|
|
198
|
+
),
|
|
199
|
+
)
|
|
182
200
|
|
|
183
201
|
# group_normalization.add_argument(
|
|
184
202
|
# '--threshold',
|
|
@@ -636,6 +654,7 @@ def main() -> None:
|
|
|
636
654
|
dry_run=cli_args.dry_run,
|
|
637
655
|
progress=cli_args.progress,
|
|
638
656
|
replaygain=cli_args.replaygain,
|
|
657
|
+
batch=cli_args.batch,
|
|
639
658
|
audio_streams=audio_streams,
|
|
640
659
|
audio_default_only=cli_args.audio_default_only,
|
|
641
660
|
keep_other_audio=cli_args.keep_other_audio,
|
{ffmpeg_normalize-1.34.0 → ffmpeg_normalize-1.35.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
@@ -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.
|
|
@@ -127,6 +128,7 @@ class FFmpegNormalize:
|
|
|
127
128
|
debug: bool = False,
|
|
128
129
|
progress: bool = False,
|
|
129
130
|
replaygain: bool = False,
|
|
131
|
+
batch: bool = False,
|
|
130
132
|
audio_streams: list[int] | None = None,
|
|
131
133
|
audio_default_only: bool = False,
|
|
132
134
|
keep_other_audio: bool = False,
|
|
@@ -212,6 +214,7 @@ class FFmpegNormalize:
|
|
|
212
214
|
self.debug = debug
|
|
213
215
|
self.progress = progress
|
|
214
216
|
self.replaygain = replaygain
|
|
217
|
+
self.batch = batch
|
|
215
218
|
|
|
216
219
|
# Stream selection options
|
|
217
220
|
self.audio_streams = audio_streams
|
|
@@ -272,29 +275,172 @@ class FFmpegNormalize:
|
|
|
272
275
|
self.media_files.append(MediaFile(self, input_file, output_file))
|
|
273
276
|
self.file_count += 1
|
|
274
277
|
|
|
278
|
+
def _calculate_batch_reference(self) -> float | None:
|
|
279
|
+
"""
|
|
280
|
+
Calculate the batch reference loudness by averaging measurements across all files.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
float | None: The batch reference loudness value, or None if no measurements found.
|
|
284
|
+
|
|
285
|
+
Note:
|
|
286
|
+
TODO: Add option to specify different averaging methods (duration-weighted,
|
|
287
|
+
use quietest/loudest track, etc.)
|
|
288
|
+
"""
|
|
289
|
+
measurements: list[float] = []
|
|
290
|
+
|
|
291
|
+
for media_file in self.media_files:
|
|
292
|
+
# Access audio streams from the streams dict
|
|
293
|
+
audio_streams = media_file.streams.get("audio", {})
|
|
294
|
+
for stream in audio_streams.values():
|
|
295
|
+
if self.normalization_type == "ebu":
|
|
296
|
+
# Get EBU integrated loudness from first pass
|
|
297
|
+
ebu_stats = stream.loudness_statistics.get("ebu_pass1")
|
|
298
|
+
if ebu_stats and "input_i" in ebu_stats:
|
|
299
|
+
measurements.append(float(ebu_stats["input_i"]))
|
|
300
|
+
elif self.normalization_type == "rms":
|
|
301
|
+
# Get RMS mean value
|
|
302
|
+
mean = stream.loudness_statistics.get("mean")
|
|
303
|
+
if mean is not None:
|
|
304
|
+
measurements.append(float(mean))
|
|
305
|
+
elif self.normalization_type == "peak":
|
|
306
|
+
# Get peak max value
|
|
307
|
+
max_val = stream.loudness_statistics.get("max")
|
|
308
|
+
if max_val is not None:
|
|
309
|
+
measurements.append(float(max_val))
|
|
310
|
+
|
|
311
|
+
if not measurements:
|
|
312
|
+
_logger.warning(
|
|
313
|
+
"No loudness measurements found for batch reference calculation. "
|
|
314
|
+
"Batch mode will not be applied."
|
|
315
|
+
)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
# Simple average of all measurements
|
|
319
|
+
batch_reference = sum(measurements) / len(measurements)
|
|
320
|
+
_logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
|
|
321
|
+
_logger.info(
|
|
322
|
+
f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
|
|
323
|
+
f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return batch_reference
|
|
327
|
+
|
|
275
328
|
def run_normalization(self) -> None:
|
|
276
329
|
"""
|
|
277
|
-
Run the normalization procedures
|
|
330
|
+
Run the normalization procedures.
|
|
331
|
+
|
|
332
|
+
In batch mode, all files are analyzed first (first pass), then a batch reference
|
|
333
|
+
loudness is calculated, and finally all files are normalized (second pass) with
|
|
334
|
+
adjustments relative to the batch reference to preserve relative loudness.
|
|
335
|
+
|
|
336
|
+
In non-batch mode, each file is processed completely (both passes) before
|
|
337
|
+
moving to the next file.
|
|
278
338
|
"""
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
):
|
|
339
|
+
if self.batch:
|
|
340
|
+
# Batch mode: analyze all files first, then normalize with relative adjustments
|
|
282
341
|
_logger.info(
|
|
283
|
-
f"
|
|
342
|
+
f"Batch mode enabled: processing {self.file_count} file(s) while preserving relative loudness"
|
|
284
343
|
)
|
|
285
344
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
345
|
+
# Recommend RMS/Peak for album normalization instead of EBU
|
|
346
|
+
if self.normalization_type == "ebu":
|
|
347
|
+
_logger.warning(
|
|
348
|
+
"Using EBU R128 normalization with --batch. For true album normalization where "
|
|
349
|
+
"all tracks are shifted by the same amount, consider using --normalization-type rms "
|
|
350
|
+
"or --normalization-type peak instead. EBU normalization applies different processing "
|
|
351
|
+
"to each track based on its loudness characteristics, which may alter relative levels "
|
|
352
|
+
"slightly due to psychoacoustic adjustments."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Warn if using dynamic EBU mode with batch
|
|
356
|
+
if self.dynamic and self.normalization_type == "ebu":
|
|
357
|
+
_logger.warning(
|
|
358
|
+
"ffmpeg uses dynamic EBU normalization. This may change relative "
|
|
359
|
+
"loudness within a file. Use linear mode for true album normalization, or "
|
|
360
|
+
"switch to --normalization-type peak or --normalization-type rms instead. "
|
|
361
|
+
"To force linear mode, use --keep-lra-above-loudness-range-target or "
|
|
362
|
+
"--keep-loudness-range-target."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Phase 1: Run first pass on all files to collect measurements
|
|
366
|
+
_logger.info("Phase 1: Analyzing all files...")
|
|
367
|
+
for index, media_file in enumerate(
|
|
368
|
+
tqdm(
|
|
369
|
+
self.media_files,
|
|
370
|
+
desc="Analysis",
|
|
371
|
+
disable=not self.progress,
|
|
372
|
+
position=0,
|
|
373
|
+
)
|
|
374
|
+
):
|
|
375
|
+
_logger.info(
|
|
376
|
+
f"Analyzing file {media_file} ({index + 1} of {self.file_count})"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
# Only run first pass if not in dynamic EBU mode
|
|
381
|
+
if not (self.dynamic and self.normalization_type == "ebu"):
|
|
382
|
+
media_file._first_pass()
|
|
383
|
+
else:
|
|
384
|
+
_logger.debug(
|
|
385
|
+
"Dynamic EBU mode: First pass skipped for this file."
|
|
386
|
+
)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
if len(self.media_files) > 1:
|
|
389
|
+
_logger.error(
|
|
390
|
+
f"Error analyzing input file {media_file}, will "
|
|
391
|
+
f"continue batch-processing. Error was: {e}"
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
raise e
|
|
395
|
+
|
|
396
|
+
# Phase 2: Calculate batch reference loudness
|
|
397
|
+
batch_reference = self._calculate_batch_reference()
|
|
398
|
+
|
|
399
|
+
# Phase 3: Run second pass on all files with batch reference
|
|
400
|
+
_logger.info("Phase 2: Normalizing all files...")
|
|
401
|
+
for index, media_file in enumerate(
|
|
402
|
+
tqdm(
|
|
403
|
+
self.media_files,
|
|
404
|
+
desc="Normalization",
|
|
405
|
+
disable=not self.progress,
|
|
406
|
+
position=0,
|
|
407
|
+
)
|
|
408
|
+
):
|
|
409
|
+
_logger.info(
|
|
410
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
media_file.run_normalization(batch_reference=batch_reference)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
if len(self.media_files) > 1:
|
|
417
|
+
_logger.error(
|
|
418
|
+
f"Error processing input file {media_file}, will "
|
|
419
|
+
f"continue batch-processing. Error was: {e}"
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
raise e
|
|
423
|
+
else:
|
|
424
|
+
# Non-batch mode: process each file completely before moving to the next
|
|
425
|
+
for index, media_file in enumerate(
|
|
426
|
+
tqdm(
|
|
427
|
+
self.media_files, desc="File", disable=not self.progress, position=0
|
|
428
|
+
)
|
|
429
|
+
):
|
|
430
|
+
_logger.info(
|
|
431
|
+
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
media_file.run_normalization()
|
|
436
|
+
except Exception as e:
|
|
437
|
+
if len(self.media_files) > 1:
|
|
438
|
+
_logger.error(
|
|
439
|
+
f"Error processing input file {media_file}, will "
|
|
440
|
+
f"continue batch-processing. Error was: {e}"
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
raise e
|
|
298
444
|
|
|
299
445
|
if self.print_stats:
|
|
300
446
|
json.dump(
|
|
@@ -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}]"
|
|
@@ -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":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|