ffmpeg-normalize 1.43.1__tar.gz → 1.44.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.43.1 → ffmpeg_normalize-1.44.0}/PKG-INFO +1 -1
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/pyproject.toml +1 -1
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/pyproject.toml.orig +1 -1
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__main__.py +68 -43
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_cmd_utils.py +23 -3
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +57 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_media_file.py +105 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_streams.py +45 -4
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/README.md +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_presets.py +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/py.typed +0 -0
|
@@ -218,12 +218,31 @@ def create_parser(suppress_defaults: bool = False) -> argparse.ArgumentParser:
|
|
|
218
218
|
output level, the output peak, the applied gain, and the status. The
|
|
219
219
|
level is the integrated loudness for EBU, the RMS level for RMS, and the
|
|
220
220
|
peak level for peak normalization. Values that are not available are
|
|
221
|
-
shown as `-`.
|
|
221
|
+
shown as `-`. Output values are filter statistics (EBU) or calculated
|
|
222
|
+
from gain (RMS/peak). Use --verify for separate encoded-output measurements.
|
|
222
223
|
|
|
223
224
|
Can be combined with `--print-stats`, which writes JSON to stdout.
|
|
224
225
|
"""
|
|
225
226
|
),
|
|
226
227
|
)
|
|
228
|
+
group_normalization.add_argument(
|
|
229
|
+
"--verify",
|
|
230
|
+
action="store_true",
|
|
231
|
+
help="Measure the encoded output and fail if level or peak checks exceed tolerance. "
|
|
232
|
+
"Skipped during dry runs; incompatible with --replaygain.",
|
|
233
|
+
)
|
|
234
|
+
group_normalization.add_argument(
|
|
235
|
+
"--verify-loudness-tolerance",
|
|
236
|
+
type=float,
|
|
237
|
+
default=FFmpegNormalize.DEFAULTS["verify_loudness_tolerance"],
|
|
238
|
+
help="Allowed deviation from the effective output level in dB/LU (default: 0.5).",
|
|
239
|
+
)
|
|
240
|
+
group_normalization.add_argument(
|
|
241
|
+
"--verify-peak-tolerance",
|
|
242
|
+
type=float,
|
|
243
|
+
default=FFmpegNormalize.DEFAULTS["verify_peak_tolerance"],
|
|
244
|
+
help="Allowed overshoot above the output peak ceiling in dB (default: 0.1).",
|
|
245
|
+
)
|
|
227
246
|
group_normalization.add_argument(
|
|
228
247
|
"--replaygain",
|
|
229
248
|
action="store_true",
|
|
@@ -763,48 +782,54 @@ def main() -> None:
|
|
|
763
782
|
if cli_args.audio_default_only and cli_args.audio_streams:
|
|
764
783
|
error("Cannot use both --audio-default-only and --audio-streams together.")
|
|
765
784
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
785
|
+
try:
|
|
786
|
+
ffmpeg_normalize = FFmpegNormalize(
|
|
787
|
+
normalization_type=cli_args.normalization_type,
|
|
788
|
+
target_level=cli_args.target_level,
|
|
789
|
+
print_stats=cli_args.print_stats,
|
|
790
|
+
print_summary=cli_args.print_summary,
|
|
791
|
+
threshold=cli_args.threshold,
|
|
792
|
+
loudness_range_target=cli_args.loudness_range_target,
|
|
793
|
+
keep_loudness_range_target=cli_args.keep_loudness_range_target,
|
|
794
|
+
keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
|
|
795
|
+
true_peak=cli_args.true_peak,
|
|
796
|
+
offset=cli_args.offset,
|
|
797
|
+
lower_only=cli_args.lower_only,
|
|
798
|
+
auto_lower_loudness_target=cli_args.auto_lower_loudness_target,
|
|
799
|
+
dual_mono=cli_args.dual_mono,
|
|
800
|
+
dynamic=cli_args.dynamic,
|
|
801
|
+
audio_codec=cli_args.audio_codec,
|
|
802
|
+
audio_bitrate=cli_args.audio_bitrate,
|
|
803
|
+
sample_rate=cli_args.sample_rate,
|
|
804
|
+
audio_channels=cli_args.audio_channels,
|
|
805
|
+
keep_original_audio=cli_args.keep_original_audio,
|
|
806
|
+
pre_filter=cli_args.pre_filter,
|
|
807
|
+
post_filter=cli_args.post_filter,
|
|
808
|
+
video_codec=cli_args.video_codec,
|
|
809
|
+
video_disable=cli_args.video_disable,
|
|
810
|
+
subtitle_disable=cli_args.subtitle_disable,
|
|
811
|
+
metadata_disable=cli_args.metadata_disable,
|
|
812
|
+
chapters_disable=cli_args.chapters_disable,
|
|
813
|
+
write_encoder_settings=cli_args.write_encoder_settings,
|
|
814
|
+
extra_input_options=extra_input_options,
|
|
815
|
+
extra_output_options=extra_output_options,
|
|
816
|
+
output_format=cli_args.output_format,
|
|
817
|
+
extension=cli_args.extension,
|
|
818
|
+
dry_run=cli_args.dry_run,
|
|
819
|
+
progress=cli_args.progress,
|
|
820
|
+
replaygain=cli_args.replaygain,
|
|
821
|
+
batch=cli_args.batch,
|
|
822
|
+
audio_streams=audio_streams,
|
|
823
|
+
audio_default_only=cli_args.audio_default_only,
|
|
824
|
+
keep_other_audio=cli_args.keep_other_audio,
|
|
825
|
+
keep_mtime=cli_args.keep_mtime,
|
|
826
|
+
keep_bit_depth=cli_args.keep_bit_depth,
|
|
827
|
+
verify=cli_args.verify,
|
|
828
|
+
verify_loudness_tolerance=cli_args.verify_loudness_tolerance,
|
|
829
|
+
verify_peak_tolerance=cli_args.verify_peak_tolerance,
|
|
830
|
+
)
|
|
831
|
+
except FFmpegNormalizeError as e:
|
|
832
|
+
error(e)
|
|
808
833
|
|
|
809
834
|
if cli_args.output and len(cli_args.input) > len(cli_args.output):
|
|
810
835
|
_logger.warning(
|
|
@@ -179,7 +179,11 @@ class CommandRunner:
|
|
|
179
179
|
stderr = stderr_bytes.decode("utf8", errors="replace")
|
|
180
180
|
|
|
181
181
|
if p.returncode != 0:
|
|
182
|
-
|
|
182
|
+
reason = (
|
|
183
|
+
stderr.strip()
|
|
184
|
+
or f"exited with status {p.returncode} without diagnostics"
|
|
185
|
+
)
|
|
186
|
+
raise RuntimeError(f"Error running command {shlex.join(cmd)}: {reason}")
|
|
183
187
|
|
|
184
188
|
self.output = stdout + stderr
|
|
185
189
|
return self
|
|
@@ -219,7 +223,9 @@ def get_ffmpeg_exe() -> str:
|
|
|
219
223
|
if ff_path := os.getenv("FFMPEG_PATH"):
|
|
220
224
|
if os.sep in ff_path:
|
|
221
225
|
if not os.path.isfile(ff_path):
|
|
222
|
-
raise FFmpegNormalizeError(
|
|
226
|
+
raise FFmpegNormalizeError(
|
|
227
|
+
f"ffmpeg executable does not exist: {ff_path}"
|
|
228
|
+
)
|
|
223
229
|
|
|
224
230
|
return ff_path
|
|
225
231
|
|
|
@@ -392,7 +398,21 @@ def ffmpeg_has_loudnorm() -> bool:
|
|
|
392
398
|
Returns:
|
|
393
399
|
bool: True if loudnorm is supported, False otherwise
|
|
394
400
|
"""
|
|
395
|
-
|
|
401
|
+
executable = get_ffmpeg_exe()
|
|
402
|
+
command = [executable, "-filters"]
|
|
403
|
+
try:
|
|
404
|
+
output = CommandRunner().run_command(command).get_output()
|
|
405
|
+
except (OSError, RuntimeError) as error:
|
|
406
|
+
_logger.debug("ffmpeg startup failed", exc_info=True)
|
|
407
|
+
reason = (
|
|
408
|
+
str(error)
|
|
409
|
+
.removeprefix(f"Error running command {shlex.join(command)}: ")
|
|
410
|
+
.strip()
|
|
411
|
+
)
|
|
412
|
+
reason = " ".join(reason.splitlines()[:2])
|
|
413
|
+
raise FFmpegNormalizeError(
|
|
414
|
+
f"Could not start ffmpeg '{executable}': {reason}"
|
|
415
|
+
) from error
|
|
396
416
|
supports_loudnorm = "loudnorm" in output
|
|
397
417
|
if not supports_loudnorm:
|
|
398
418
|
_logger.error(
|
{ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
@@ -99,6 +99,9 @@ class FFmpegNormalize:
|
|
|
99
99
|
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
|
|
100
100
|
keep_mtime (bool, optional): Copy the input file's modification time to the output file. Defaults to False.
|
|
101
101
|
keep_bit_depth (bool, optional): Carry the detected input bit depth through to the output encoder. Defaults to True.
|
|
102
|
+
verify (bool, optional): Measure the encoded output and check its effective target and peak ceiling. Defaults to False. Not available with ReplayGain; skipped during dry runs.
|
|
103
|
+
verify_loudness_tolerance (float, optional): Allowed level deviation in dB/LU. Defaults to 0.5.
|
|
104
|
+
verify_peak_tolerance (float, optional): Allowed peak overshoot in dB. Defaults to 0.1.
|
|
102
105
|
|
|
103
106
|
Raises:
|
|
104
107
|
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
@@ -149,6 +152,9 @@ class FFmpegNormalize:
|
|
|
149
152
|
"keep_other_audio": False,
|
|
150
153
|
"keep_mtime": False,
|
|
151
154
|
"keep_bit_depth": True,
|
|
155
|
+
"verify": False,
|
|
156
|
+
"verify_loudness_tolerance": 0.5,
|
|
157
|
+
"verify_peak_tolerance": 0.1,
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
def __init__(
|
|
@@ -194,6 +200,9 @@ class FFmpegNormalize:
|
|
|
194
200
|
keep_other_audio: bool = False,
|
|
195
201
|
keep_mtime: bool = False,
|
|
196
202
|
keep_bit_depth: bool = True,
|
|
203
|
+
verify: bool = False,
|
|
204
|
+
verify_loudness_tolerance: float = 0.5,
|
|
205
|
+
verify_peak_tolerance: float = 0.1,
|
|
197
206
|
):
|
|
198
207
|
self.ffmpeg_exe = get_ffmpeg_exe()
|
|
199
208
|
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
|
|
@@ -293,6 +302,20 @@ class FFmpegNormalize:
|
|
|
293
302
|
|
|
294
303
|
self.keep_mtime = keep_mtime
|
|
295
304
|
self.keep_bit_depth = keep_bit_depth
|
|
305
|
+
self.verify = verify
|
|
306
|
+
self.verify_loudness_tolerance = check_range(
|
|
307
|
+
verify_loudness_tolerance, 0, 99, name="verify_loudness_tolerance"
|
|
308
|
+
)
|
|
309
|
+
self.verify_peak_tolerance = check_range(
|
|
310
|
+
verify_peak_tolerance, 0, 99, name="verify_peak_tolerance"
|
|
311
|
+
)
|
|
312
|
+
if not all(
|
|
313
|
+
math.isfinite(value)
|
|
314
|
+
for value in (self.verify_loudness_tolerance, self.verify_peak_tolerance)
|
|
315
|
+
):
|
|
316
|
+
raise FFmpegNormalizeError("Verification tolerances must be finite")
|
|
317
|
+
if verify and replaygain:
|
|
318
|
+
raise FFmpegNormalizeError("--verify cannot be combined with --replaygain")
|
|
296
319
|
|
|
297
320
|
if self.output_format in PCM_INCOMPATIBLE_FORMATS:
|
|
298
321
|
if self.audio_codec is not None and "pcm" in self.audio_codec:
|
|
@@ -508,6 +531,17 @@ class FFmpegNormalize:
|
|
|
508
531
|
In non-batch mode, each file is processed completely (both passes) before
|
|
509
532
|
moving to the next file.
|
|
510
533
|
"""
|
|
534
|
+
if (
|
|
535
|
+
self.verify
|
|
536
|
+
and not self.dry_run
|
|
537
|
+
and any(
|
|
538
|
+
os.path.realpath(file.output_file) == os.path.realpath(os.devnull)
|
|
539
|
+
for file in self.media_files
|
|
540
|
+
)
|
|
541
|
+
):
|
|
542
|
+
raise FFmpegNormalizeError(
|
|
543
|
+
"--verify requires an output file, not the null device"
|
|
544
|
+
)
|
|
511
545
|
if not self.replaygain:
|
|
512
546
|
self.validate_output_files(
|
|
513
547
|
[media_file.input_file for media_file in self.media_files],
|
|
@@ -591,6 +625,8 @@ class FFmpegNormalize:
|
|
|
591
625
|
|
|
592
626
|
try:
|
|
593
627
|
media_file.run_normalization(batch_reference=batch_reference)
|
|
628
|
+
if self.verify and not self.dry_run:
|
|
629
|
+
media_file.verify_output()
|
|
594
630
|
except Exception as e:
|
|
595
631
|
media_file.status = "error"
|
|
596
632
|
media_file.error = str(e)
|
|
@@ -609,6 +645,8 @@ class FFmpegNormalize:
|
|
|
609
645
|
|
|
610
646
|
try:
|
|
611
647
|
media_file.run_normalization()
|
|
648
|
+
if self.verify and not self.dry_run:
|
|
649
|
+
media_file.verify_output()
|
|
612
650
|
except Exception as e:
|
|
613
651
|
media_file.status = "error"
|
|
614
652
|
media_file.error = str(e)
|
|
@@ -719,4 +757,23 @@ class FFmpegNormalize:
|
|
|
719
757
|
lines = [f"Summary ({header}):", "", fmt_line(headings)]
|
|
720
758
|
lines.append(fmt_line(["-" * w for w in widths]))
|
|
721
759
|
lines.extend(fmt_line(line) for line in table)
|
|
760
|
+
lines.append(
|
|
761
|
+
"\nOutput values above: loudnorm filter statistics for EBU; "
|
|
762
|
+
"calculated from applied gain for RMS/peak; input levels for skipped files."
|
|
763
|
+
)
|
|
764
|
+
verified = [row for row in rows if "verification" in row]
|
|
765
|
+
if verified:
|
|
766
|
+
level_unit = "LUFS" if self.normalization_type == "ebu" else "dBFS"
|
|
767
|
+
peak_unit = "dBTP" if self.normalization_type == "ebu" else "dBFS"
|
|
768
|
+
lines.append("\nEncoded-output verification (measured after encoding):")
|
|
769
|
+
for row in verified:
|
|
770
|
+
result = row["verification"]
|
|
771
|
+
outcome = "passed" if result["passed"] else "FAILED"
|
|
772
|
+
lines.append(
|
|
773
|
+
f"{row['output_file']} stream {result['output_stream_id']}: "
|
|
774
|
+
f"level {fmt(result['output_level'])} {level_unit}, "
|
|
775
|
+
f"peak {fmt(result['output_peak'])} {peak_unit}, {outcome}"
|
|
776
|
+
)
|
|
777
|
+
elif self.verify and self.dry_run:
|
|
778
|
+
lines.append("\nEncoded-output verification skipped: dry run.")
|
|
722
779
|
return "\n".join(lines)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
5
|
+
from copy import copy
|
|
4
6
|
import os
|
|
5
7
|
import re
|
|
6
8
|
import shlex
|
|
@@ -29,6 +31,7 @@ from ._streams import (
|
|
|
29
31
|
SubtitleStream,
|
|
30
32
|
SummaryRow,
|
|
31
33
|
VideoStream,
|
|
34
|
+
VerificationResult,
|
|
32
35
|
)
|
|
33
36
|
|
|
34
37
|
if TYPE_CHECKING:
|
|
@@ -383,6 +386,108 @@ class MediaFile:
|
|
|
383
386
|
self._apply_input_timestamps()
|
|
384
387
|
_logger.info(f"Normalized file written to {self.output_file}")
|
|
385
388
|
|
|
389
|
+
def verify_output(self) -> None:
|
|
390
|
+
"""Verify encoded audio, restoring requested timestamps after reading it."""
|
|
391
|
+
try:
|
|
392
|
+
self._verify_output()
|
|
393
|
+
finally:
|
|
394
|
+
if self.ffmpeg_normalize.keep_mtime:
|
|
395
|
+
self._apply_input_timestamps()
|
|
396
|
+
|
|
397
|
+
def _verify_output(self) -> None:
|
|
398
|
+
"""Measure encoded audio without reapplying filters or input options.
|
|
399
|
+
|
|
400
|
+
Only normalized streams are checked. They precede passthrough and
|
|
401
|
+
original audio in encoded outputs; skipped files retain input indices.
|
|
402
|
+
Verification keeps the encoded file even if a check fails.
|
|
403
|
+
"""
|
|
404
|
+
config = copy(self.ffmpeg_normalize)
|
|
405
|
+
config.pre_filter = config.post_filter = None
|
|
406
|
+
config.extra_input_options = None
|
|
407
|
+
config.audio_channels = None
|
|
408
|
+
measured_file = MediaFile(config, self.output_file, os.devnull)
|
|
409
|
+
selected = self._get_streams_to_normalize()
|
|
410
|
+
output_streams = list(measured_file.streams["audio"].values())
|
|
411
|
+
if self.status == "skipped":
|
|
412
|
+
output_streams = [
|
|
413
|
+
measured_file.streams["audio"][stream.stream_id] for stream in selected
|
|
414
|
+
]
|
|
415
|
+
if not selected or len(output_streams) < len(selected):
|
|
416
|
+
raise FFmpegNormalizeError(
|
|
417
|
+
"Cannot verify output: selected audio streams are missing"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
failures: list[str] = []
|
|
421
|
+
for source, measured in zip(selected, output_streams):
|
|
422
|
+
summary = source.get_summary()
|
|
423
|
+
expected = summary["output_level"]
|
|
424
|
+
if config.normalization_type == "ebu":
|
|
425
|
+
for _ in measured.parse_loudnorm_stats():
|
|
426
|
+
pass
|
|
427
|
+
stats = measured.loudness_statistics["ebu_pass1"]
|
|
428
|
+
if stats is None:
|
|
429
|
+
raise FFmpegNormalizeError(
|
|
430
|
+
"Cannot verify output: loudness measurement is missing"
|
|
431
|
+
)
|
|
432
|
+
level, peak = float(stats["input_i"]), float(stats["input_tp"])
|
|
433
|
+
if self.status != "skipped":
|
|
434
|
+
expected = source.verification_target_level
|
|
435
|
+
if expected is None:
|
|
436
|
+
expected = summary["input_level"]
|
|
437
|
+
peak_limit = config.true_peak
|
|
438
|
+
else:
|
|
439
|
+
for _ in measured.parse_astats():
|
|
440
|
+
pass
|
|
441
|
+
level = measured.loudness_statistics[
|
|
442
|
+
"mean" if config.normalization_type == "rms" else "max"
|
|
443
|
+
]
|
|
444
|
+
peak = measured.loudness_statistics["max"]
|
|
445
|
+
peak_limit = (
|
|
446
|
+
config.target_level if config.normalization_type == "peak" else 0.0
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if self.status == "skipped" and summary["output_peak"] is not None:
|
|
450
|
+
peak_limit = summary["output_peak"]
|
|
451
|
+
|
|
452
|
+
def finite(value: float | None) -> float | None:
|
|
453
|
+
return value if value is not None and math.isfinite(value) else None
|
|
454
|
+
|
|
455
|
+
level, peak, expected = finite(level), finite(peak), finite(expected)
|
|
456
|
+
errors: list[str] = []
|
|
457
|
+
if level is None or expected is None:
|
|
458
|
+
errors.append("output level or expected level is not finite")
|
|
459
|
+
elif abs(level - expected) > config.verify_loudness_tolerance:
|
|
460
|
+
errors.append(
|
|
461
|
+
f"level {level:.2f} differs from expected {expected:.2f} "
|
|
462
|
+
f"by more than {config.verify_loudness_tolerance:g} dB/LU"
|
|
463
|
+
)
|
|
464
|
+
if peak is None:
|
|
465
|
+
errors.append("output peak is not finite")
|
|
466
|
+
elif peak > peak_limit + config.verify_peak_tolerance:
|
|
467
|
+
errors.append(
|
|
468
|
+
f"peak {peak:.2f} exceeds ceiling {peak_limit:.2f} "
|
|
469
|
+
f"plus {config.verify_peak_tolerance:g} dB tolerance"
|
|
470
|
+
)
|
|
471
|
+
result: VerificationResult = {
|
|
472
|
+
"output_stream_id": measured.stream_id,
|
|
473
|
+
"output_level": level,
|
|
474
|
+
"output_peak": peak,
|
|
475
|
+
"expected_level": expected,
|
|
476
|
+
"peak_limit": peak_limit,
|
|
477
|
+
"loudness_tolerance": config.verify_loudness_tolerance,
|
|
478
|
+
"peak_tolerance": config.verify_peak_tolerance,
|
|
479
|
+
"passed": not errors,
|
|
480
|
+
"errors": errors,
|
|
481
|
+
}
|
|
482
|
+
source.verification = result
|
|
483
|
+
if errors:
|
|
484
|
+
failures.append(f"stream {source.stream_id}: " + "; ".join(errors))
|
|
485
|
+
if failures:
|
|
486
|
+
raise FFmpegNormalizeError(
|
|
487
|
+
"Encoded-output verification failed: " + "; ".join(failures)
|
|
488
|
+
)
|
|
489
|
+
_logger.info(f"Encoded-output verification passed for {self.output_file}")
|
|
490
|
+
|
|
386
491
|
def _is_within_threshold(self) -> bool:
|
|
387
492
|
"""
|
|
388
493
|
Return whether every stream selected for normalization is already
|
|
@@ -59,7 +59,23 @@ class LoudnessStatistics(TypedDict):
|
|
|
59
59
|
max: float | None
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
class
|
|
62
|
+
class VerificationResult(TypedDict):
|
|
63
|
+
output_stream_id: int
|
|
64
|
+
output_level: float | None
|
|
65
|
+
output_peak: float | None
|
|
66
|
+
expected_level: float | None
|
|
67
|
+
peak_limit: float
|
|
68
|
+
loudness_tolerance: float
|
|
69
|
+
peak_tolerance: float
|
|
70
|
+
passed: bool
|
|
71
|
+
errors: list[str]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _OptionalVerification(TypedDict, total=False):
|
|
75
|
+
verification: VerificationResult
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SummaryRow(_OptionalVerification):
|
|
63
79
|
input_file: str
|
|
64
80
|
output_file: str
|
|
65
81
|
stream_id: int
|
|
@@ -68,9 +84,10 @@ class SummaryRow(TypedDict):
|
|
|
68
84
|
output_level: float | None
|
|
69
85
|
output_peak: float | None
|
|
70
86
|
gain: float | None
|
|
87
|
+
output_source: str
|
|
71
88
|
|
|
72
89
|
|
|
73
|
-
class _OptionalStatisticsMetadata(
|
|
90
|
+
class _OptionalStatisticsMetadata(_OptionalVerification, total=False):
|
|
74
91
|
error: str
|
|
75
92
|
|
|
76
93
|
|
|
@@ -165,6 +182,8 @@ class AudioStream(MediaStream):
|
|
|
165
182
|
# when normalization is skipped for this stream (e.g. with lower_only).
|
|
166
183
|
# Stays None for EBU, where the gain is derived from the second pass stats.
|
|
167
184
|
self.applied_gain: float | None = None
|
|
185
|
+
self.verification_target_level: float | None = None
|
|
186
|
+
self.verification: VerificationResult | None = None
|
|
168
187
|
|
|
169
188
|
self.sample_rate = sample_rate
|
|
170
189
|
self.bit_depth = bit_depth
|
|
@@ -218,6 +237,8 @@ class AudioStream(MediaStream):
|
|
|
218
237
|
"max": self.loudness_statistics["max"],
|
|
219
238
|
"status": self.media_file.status,
|
|
220
239
|
}
|
|
240
|
+
if self.verification is not None:
|
|
241
|
+
stats["verification"] = self.verification
|
|
221
242
|
# Only present when the file failed to process, per the per-file outcome
|
|
222
243
|
# reporting (status is "error").
|
|
223
244
|
if self.media_file.error is not None:
|
|
@@ -232,7 +253,9 @@ class AudioStream(MediaStream):
|
|
|
232
253
|
second pass in dynamic mode), and the output level, output true peak and
|
|
233
254
|
gain come from the second pass. For RMS and peak, the input level is the
|
|
234
255
|
measured RMS or peak level, and the output values are computed from the
|
|
235
|
-
applied gain. Values that are not available are None.
|
|
256
|
+
applied gain. Values that are not available are None. The output_source
|
|
257
|
+
field labels these values; verification, when present, contains separate
|
|
258
|
+
measurements of the encoded output and the check results.
|
|
236
259
|
|
|
237
260
|
Returns:
|
|
238
261
|
dict: A dictionary with the summary values.
|
|
@@ -285,7 +308,19 @@ class AudioStream(MediaStream):
|
|
|
285
308
|
if status == "error":
|
|
286
309
|
output_level = output_peak = gain = None
|
|
287
310
|
|
|
288
|
-
|
|
311
|
+
source = "unavailable"
|
|
312
|
+
if output_level is not None:
|
|
313
|
+
source = (
|
|
314
|
+
"input"
|
|
315
|
+
if status == "skipped"
|
|
316
|
+
else (
|
|
317
|
+
"filter"
|
|
318
|
+
if normalization_type == "ebu"
|
|
319
|
+
and self.loudness_statistics["ebu_pass2"] is not None
|
|
320
|
+
else "calculated"
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
summary: SummaryRow = {
|
|
289
324
|
"input_file": self.media_file.input_file,
|
|
290
325
|
"output_file": self.media_file.output_file,
|
|
291
326
|
"stream_id": self.stream_id,
|
|
@@ -294,7 +329,11 @@ class AudioStream(MediaStream):
|
|
|
294
329
|
"output_level": output_level,
|
|
295
330
|
"output_peak": output_peak,
|
|
296
331
|
"gain": gain,
|
|
332
|
+
"output_source": source,
|
|
297
333
|
}
|
|
334
|
+
if self.verification is not None:
|
|
335
|
+
summary["verification"] = self.verification
|
|
336
|
+
return summary
|
|
298
337
|
|
|
299
338
|
def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
|
|
300
339
|
"""
|
|
@@ -701,6 +740,7 @@ class AudioStream(MediaStream):
|
|
|
701
740
|
if self.media_file.ffmpeg_normalize.dual_mono:
|
|
702
741
|
opts["dual_mono"] = "true"
|
|
703
742
|
|
|
743
|
+
self.verification_target_level = float(opts["i"])
|
|
704
744
|
return "loudnorm=" + dict_to_filter_opts(opts)
|
|
705
745
|
|
|
706
746
|
if not self.loudness_statistics["ebu_pass1"]:
|
|
@@ -843,6 +883,7 @@ class AudioStream(MediaStream):
|
|
|
843
883
|
if self.media_file.ffmpeg_normalize.dual_mono:
|
|
844
884
|
opts["dual_mono"] = "true"
|
|
845
885
|
|
|
886
|
+
self.verification_target_level = target_level
|
|
846
887
|
return "loudnorm=" + dict_to_filter_opts(opts)
|
|
847
888
|
|
|
848
889
|
def get_second_pass_opts_peakrms(self, batch_reference: float | None = None) -> str:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|