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.
Files changed (18) hide show
  1. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/PKG-INFO +1 -1
  2. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/pyproject.toml +1 -1
  3. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/pyproject.toml.orig +1 -1
  4. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__main__.py +68 -43
  5. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_cmd_utils.py +23 -3
  6. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +57 -0
  7. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_media_file.py +105 -0
  8. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_streams.py +45 -4
  9. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/LICENSE.md +0 -0
  10. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/README.md +0 -0
  11. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__init__.py +0 -0
  12. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_errors.py +0 -0
  13. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_logger.py +0 -0
  14. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_presets.py +0 -0
  15. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.43.1 → ffmpeg_normalize-1.44.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.43.1
3
+ Version: 1.44.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.43.1"
7
+ version = "1.44.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.43.1"
7
+ version = "1.44.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- ffmpeg_normalize = FFmpegNormalize(
767
- normalization_type=cli_args.normalization_type,
768
- target_level=cli_args.target_level,
769
- print_stats=cli_args.print_stats,
770
- print_summary=cli_args.print_summary,
771
- threshold=cli_args.threshold,
772
- loudness_range_target=cli_args.loudness_range_target,
773
- keep_loudness_range_target=cli_args.keep_loudness_range_target,
774
- keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
775
- true_peak=cli_args.true_peak,
776
- offset=cli_args.offset,
777
- lower_only=cli_args.lower_only,
778
- auto_lower_loudness_target=cli_args.auto_lower_loudness_target,
779
- dual_mono=cli_args.dual_mono,
780
- dynamic=cli_args.dynamic,
781
- audio_codec=cli_args.audio_codec,
782
- audio_bitrate=cli_args.audio_bitrate,
783
- sample_rate=cli_args.sample_rate,
784
- audio_channels=cli_args.audio_channels,
785
- keep_original_audio=cli_args.keep_original_audio,
786
- pre_filter=cli_args.pre_filter,
787
- post_filter=cli_args.post_filter,
788
- video_codec=cli_args.video_codec,
789
- video_disable=cli_args.video_disable,
790
- subtitle_disable=cli_args.subtitle_disable,
791
- metadata_disable=cli_args.metadata_disable,
792
- chapters_disable=cli_args.chapters_disable,
793
- write_encoder_settings=cli_args.write_encoder_settings,
794
- extra_input_options=extra_input_options,
795
- extra_output_options=extra_output_options,
796
- output_format=cli_args.output_format,
797
- extension=cli_args.extension,
798
- dry_run=cli_args.dry_run,
799
- progress=cli_args.progress,
800
- replaygain=cli_args.replaygain,
801
- batch=cli_args.batch,
802
- audio_streams=audio_streams,
803
- audio_default_only=cli_args.audio_default_only,
804
- keep_other_audio=cli_args.keep_other_audio,
805
- keep_mtime=cli_args.keep_mtime,
806
- keep_bit_depth=cli_args.keep_bit_depth,
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
- raise RuntimeError(f"Error running command {shlex.join(cmd)}: {stderr}")
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(f"No file exists at {ff_path}")
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
- output = CommandRunner().run_command([get_ffmpeg_exe(), "-filters"]).get_output()
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(
@@ -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 SummaryRow(TypedDict):
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(TypedDict, total=False):
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
- return {
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: