ffmpeg-normalize 1.43.0__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.0 → ffmpeg_normalize-1.44.0}/PKG-INFO +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/pyproject.toml +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/pyproject.toml.orig +1 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__main__.py +97 -53
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_cmd_utils.py +23 -3
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +133 -9
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_media_file.py +109 -1
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_presets.py +6 -45
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_streams.py +45 -4
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/README.md +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/py.typed +0 -0
|
@@ -23,7 +23,7 @@ __version__ = importlib.metadata.version("ffmpeg-normalize")
|
|
|
23
23
|
_logger = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_parser() -> argparse.ArgumentParser:
|
|
26
|
+
def create_parser(suppress_defaults: bool = False) -> argparse.ArgumentParser:
|
|
27
27
|
parser = argparse.ArgumentParser(
|
|
28
28
|
prog="ffmpeg-normalize",
|
|
29
29
|
description=textwrap.dedent(
|
|
@@ -76,6 +76,9 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
76
76
|
will be written to the default output folder with the name `<input>.<ext>`,
|
|
77
77
|
where `<ext>` is the output extension (see `-ext` option).
|
|
78
78
|
|
|
79
|
+
Output paths must be distinct and cannot overwrite another input in the same run,
|
|
80
|
+
even with --force. Normalizing a file in place is allowed.
|
|
81
|
+
|
|
79
82
|
Example: ffmpeg-normalize 1.wav 2.wav -o 1n.wav 2n.wav
|
|
80
83
|
"""
|
|
81
84
|
),
|
|
@@ -215,12 +218,31 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
215
218
|
output level, the output peak, the applied gain, and the status. The
|
|
216
219
|
level is the integrated loudness for EBU, the RMS level for RMS, and the
|
|
217
220
|
peak level for peak normalization. Values that are not available are
|
|
218
|
-
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.
|
|
219
223
|
|
|
220
224
|
Can be combined with `--print-stats`, which writes JSON to stdout.
|
|
221
225
|
"""
|
|
222
226
|
),
|
|
223
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
|
+
)
|
|
224
246
|
group_normalization.add_argument(
|
|
225
247
|
"--replaygain",
|
|
226
248
|
action="store_true",
|
|
@@ -243,8 +265,9 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
243
265
|
level. However, a group of music files all from the same album is generally meant to be
|
|
244
266
|
listened to at the relative volumes they were recorded at. In batch mode, all the specified
|
|
245
267
|
files are considered to be part of a single album and their relative volumes are preserved.
|
|
246
|
-
|
|
247
|
-
|
|
268
|
+
RMS mode targets the average level, reducing the shared gain if needed to avoid sample
|
|
269
|
+
clipping. Peak mode sets the loudest sample across the album to the target level.
|
|
270
|
+
With --lower-only, the shared gain cannot be positive.
|
|
248
271
|
|
|
249
272
|
Batch mode works with all normalization types (EBU, RMS, peak).
|
|
250
273
|
"""
|
|
@@ -672,11 +695,16 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
672
695
|
),
|
|
673
696
|
default=FFmpegNormalize.DEFAULTS["extension"],
|
|
674
697
|
)
|
|
698
|
+
if suppress_defaults:
|
|
699
|
+
for action in parser._actions:
|
|
700
|
+
action.default = argparse.SUPPRESS
|
|
675
701
|
return parser
|
|
676
702
|
|
|
677
703
|
|
|
678
704
|
def main() -> None:
|
|
679
|
-
|
|
705
|
+
explicit_args = create_parser(suppress_defaults=True).parse_args()
|
|
706
|
+
cli_args = create_parser().parse_args([])
|
|
707
|
+
vars(cli_args).update(vars(explicit_args))
|
|
680
708
|
setup_cli_logger(arguments=cli_args)
|
|
681
709
|
|
|
682
710
|
def error(message: object) -> NoReturn:
|
|
@@ -707,7 +735,9 @@ def main() -> None:
|
|
|
707
735
|
try:
|
|
708
736
|
preset_data = preset_manager.load_preset(cli_args.preset)
|
|
709
737
|
_logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
|
|
710
|
-
preset_manager.merge_preset_with_args(
|
|
738
|
+
preset_manager.merge_preset_with_args(
|
|
739
|
+
preset_data, cli_args, set(vars(explicit_args))
|
|
740
|
+
)
|
|
711
741
|
_logger.info(f"Applied preset '{cli_args.preset}'")
|
|
712
742
|
except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
|
|
713
743
|
error(str(e))
|
|
@@ -752,48 +782,54 @@ def main() -> None:
|
|
|
752
782
|
if cli_args.audio_default_only and cli_args.audio_streams:
|
|
753
783
|
error("Cannot use both --audio-default-only and --audio-streams together.")
|
|
754
784
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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)
|
|
797
833
|
|
|
798
834
|
if cli_args.output and len(cli_args.input) > len(cli_args.output):
|
|
799
835
|
_logger.warning(
|
|
@@ -825,6 +861,7 @@ def main() -> None:
|
|
|
825
861
|
"Please fix the issues above and try again."
|
|
826
862
|
)
|
|
827
863
|
|
|
864
|
+
output_files: list[str] = []
|
|
828
865
|
for index, input_file in enumerate(input_files):
|
|
829
866
|
if cli_args.output is not None and index < len(cli_args.output):
|
|
830
867
|
if cli_args.output_folder and cli_args.output_folder != "normalized":
|
|
@@ -843,12 +880,15 @@ def main() -> None:
|
|
|
843
880
|
+ "."
|
|
844
881
|
+ cli_args.extension,
|
|
845
882
|
)
|
|
846
|
-
|
|
847
|
-
_logger.warning(
|
|
848
|
-
f"Output directory '{cli_args.output_folder}' does not exist, will create"
|
|
849
|
-
)
|
|
850
|
-
os.makedirs(cli_args.output_folder, exist_ok=True)
|
|
883
|
+
output_files.append(output_file)
|
|
851
884
|
|
|
885
|
+
try:
|
|
886
|
+
if not cli_args.replaygain:
|
|
887
|
+
FFmpegNormalize.validate_output_files(input_files, output_files)
|
|
888
|
+
except FFmpegNormalizeError as e:
|
|
889
|
+
error(e)
|
|
890
|
+
|
|
891
|
+
for input_file, output_file in zip(input_files, output_files):
|
|
852
892
|
if (
|
|
853
893
|
os.path.exists(output_file)
|
|
854
894
|
and not cli_args.force
|
|
@@ -867,6 +907,10 @@ def main() -> None:
|
|
|
867
907
|
_logger.warning(f"Input file '{input_file}' is not a file, skipping")
|
|
868
908
|
continue
|
|
869
909
|
|
|
910
|
+
output_dir = os.path.dirname(output_file)
|
|
911
|
+
if output_dir and not cli_args.dry_run:
|
|
912
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
913
|
+
|
|
870
914
|
try:
|
|
871
915
|
ffmpeg_normalize.add_media_file(input_file, output_file)
|
|
872
916
|
except FFmpegNormalizeError as e:
|
|
@@ -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.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
from itertools import chain
|
|
@@ -98,6 +99,9 @@ class FFmpegNormalize:
|
|
|
98
99
|
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
|
|
99
100
|
keep_mtime (bool, optional): Copy the input file's modification time to the output file. Defaults to False.
|
|
100
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.
|
|
101
105
|
|
|
102
106
|
Raises:
|
|
103
107
|
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
@@ -148,6 +152,9 @@ class FFmpegNormalize:
|
|
|
148
152
|
"keep_other_audio": False,
|
|
149
153
|
"keep_mtime": False,
|
|
150
154
|
"keep_bit_depth": True,
|
|
155
|
+
"verify": False,
|
|
156
|
+
"verify_loudness_tolerance": 0.5,
|
|
157
|
+
"verify_peak_tolerance": 0.1,
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
def __init__(
|
|
@@ -193,6 +200,9 @@ class FFmpegNormalize:
|
|
|
193
200
|
keep_other_audio: bool = False,
|
|
194
201
|
keep_mtime: bool = False,
|
|
195
202
|
keep_bit_depth: bool = True,
|
|
203
|
+
verify: bool = False,
|
|
204
|
+
verify_loudness_tolerance: float = 0.5,
|
|
205
|
+
verify_peak_tolerance: float = 0.1,
|
|
196
206
|
):
|
|
197
207
|
self.ffmpeg_exe = get_ffmpeg_exe()
|
|
198
208
|
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
|
|
@@ -292,6 +302,20 @@ class FFmpegNormalize:
|
|
|
292
302
|
|
|
293
303
|
self.keep_mtime = keep_mtime
|
|
294
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")
|
|
295
319
|
|
|
296
320
|
if self.output_format in PCM_INCOMPATIBLE_FORMATS:
|
|
297
321
|
if self.audio_codec is not None and "pcm" in self.audio_codec:
|
|
@@ -385,23 +409,68 @@ class FFmpegNormalize:
|
|
|
385
409
|
errors.append(error_msg)
|
|
386
410
|
return errors
|
|
387
411
|
|
|
412
|
+
@staticmethod
|
|
413
|
+
def validate_output_files(input_files: list[str], output_files: list[str]) -> None:
|
|
414
|
+
"""Reject duplicate destinations and outputs that overwrite another input.
|
|
415
|
+
|
|
416
|
+
Resolve symlinks and existing hard links, while allowing each file to be
|
|
417
|
+
normalized in place and multiple outputs to the null device.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
def path_keys(path: str) -> list[str | tuple[int, int]]:
|
|
421
|
+
keys: list[str | tuple[int, int]] = [
|
|
422
|
+
os.path.normcase(os.path.realpath(path))
|
|
423
|
+
]
|
|
424
|
+
if os.path.exists(path):
|
|
425
|
+
stat = os.stat(path)
|
|
426
|
+
keys.append((stat.st_dev, stat.st_ino))
|
|
427
|
+
return keys
|
|
428
|
+
|
|
429
|
+
input_owners: dict[str | tuple[int, int], set[int]] = {}
|
|
430
|
+
for index, path in enumerate(input_files):
|
|
431
|
+
for key in path_keys(path):
|
|
432
|
+
input_owners.setdefault(key, set()).add(index)
|
|
433
|
+
|
|
434
|
+
output_owners: dict[str | tuple[int, int], str] = {}
|
|
435
|
+
for index, path in enumerate(output_files):
|
|
436
|
+
if os.path.realpath(path) == os.path.realpath(os.devnull):
|
|
437
|
+
continue
|
|
438
|
+
for key in path_keys(path):
|
|
439
|
+
if key in output_owners:
|
|
440
|
+
raise FFmpegNormalizeError(
|
|
441
|
+
f"Output collision: '{path}' and '{output_owners[key]}' "
|
|
442
|
+
"refer to the same destination. Specify distinct output paths."
|
|
443
|
+
)
|
|
444
|
+
if input_owners.get(key, set()) - {index}:
|
|
445
|
+
raise FFmpegNormalizeError(
|
|
446
|
+
f"Output '{path}' would overwrite another input file. "
|
|
447
|
+
"Specify a different output path."
|
|
448
|
+
)
|
|
449
|
+
output_owners[key] = path
|
|
450
|
+
|
|
388
451
|
def _calculate_batch_reference(self) -> float | None:
|
|
389
452
|
"""
|
|
390
|
-
Calculate
|
|
453
|
+
Calculate a shared reference from the selected streams' measurements.
|
|
454
|
+
|
|
455
|
+
Peak mode uses the highest sample peak. RMS mode uses the average RMS,
|
|
456
|
+
with gain capped by the highest sample peak. EBU uses average LUFS.
|
|
391
457
|
|
|
392
458
|
Returns:
|
|
393
459
|
float | None: The batch reference loudness value, or None if no measurements found.
|
|
394
460
|
|
|
395
461
|
Note:
|
|
396
|
-
|
|
397
|
-
use quietest/loudest track, etc.)
|
|
462
|
+
RMS and EBU averages weight each selected stream equally.
|
|
398
463
|
"""
|
|
399
464
|
measurements: list[float] = []
|
|
465
|
+
peaks: list[float] = []
|
|
400
466
|
|
|
401
467
|
for media_file in self.media_files:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
for stream in
|
|
468
|
+
if media_file.status == "error":
|
|
469
|
+
continue
|
|
470
|
+
for stream in media_file._get_streams_to_normalize():
|
|
471
|
+
peak = stream.loudness_statistics.get("max")
|
|
472
|
+
if peak is not None and math.isfinite(peak):
|
|
473
|
+
peaks.append(float(peak))
|
|
405
474
|
if self.normalization_type == "ebu":
|
|
406
475
|
# Get EBU integrated loudness from first pass
|
|
407
476
|
ebu_stats = stream.loudness_statistics.get("ebu_pass1")
|
|
@@ -418,6 +487,7 @@ class FFmpegNormalize:
|
|
|
418
487
|
if max_val is not None:
|
|
419
488
|
measurements.append(float(max_val))
|
|
420
489
|
|
|
490
|
+
measurements = [value for value in measurements if math.isfinite(value)]
|
|
421
491
|
if not measurements:
|
|
422
492
|
_logger.warning(
|
|
423
493
|
"No loudness measurements found for batch reference calculation. "
|
|
@@ -425,12 +495,27 @@ class FFmpegNormalize:
|
|
|
425
495
|
)
|
|
426
496
|
return None
|
|
427
497
|
|
|
428
|
-
#
|
|
429
|
-
|
|
498
|
+
# Peak mode targets the loudest sample in the album. RMS mode targets
|
|
499
|
+
# the average level, but limits the shared gain to available headroom.
|
|
500
|
+
batch_reference = (
|
|
501
|
+
max(measurements)
|
|
502
|
+
if self.normalization_type == "peak"
|
|
503
|
+
else sum(measurements) / len(measurements)
|
|
504
|
+
)
|
|
505
|
+
if self.normalization_type == "rms" and peaks:
|
|
506
|
+
safe_reference = self.target_level + max(peaks)
|
|
507
|
+
if safe_reference > batch_reference:
|
|
508
|
+
_logger.warning(
|
|
509
|
+
"Reducing album gain to avoid sample clipping; "
|
|
510
|
+
"the requested RMS target will not be reached."
|
|
511
|
+
)
|
|
512
|
+
batch_reference = safe_reference
|
|
513
|
+
if self.lower_only and self.normalization_type in ("peak", "rms"):
|
|
514
|
+
batch_reference = max(batch_reference, self.target_level)
|
|
430
515
|
_logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
|
|
431
516
|
_logger.info(
|
|
432
517
|
f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
|
|
433
|
-
f"({self.normalization_type.upper()},
|
|
518
|
+
f"({self.normalization_type.upper()}, based on {len(measurements)} stream(s))"
|
|
434
519
|
)
|
|
435
520
|
|
|
436
521
|
return batch_reference
|
|
@@ -446,6 +531,22 @@ class FFmpegNormalize:
|
|
|
446
531
|
In non-batch mode, each file is processed completely (both passes) before
|
|
447
532
|
moving to the next file.
|
|
448
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
|
+
)
|
|
545
|
+
if not self.replaygain:
|
|
546
|
+
self.validate_output_files(
|
|
547
|
+
[media_file.input_file for media_file in self.media_files],
|
|
548
|
+
[media_file.output_file for media_file in self.media_files],
|
|
549
|
+
)
|
|
449
550
|
if self.batch:
|
|
450
551
|
# Batch mode: analyze all files first, then normalize with relative adjustments
|
|
451
552
|
_logger.info(
|
|
@@ -524,6 +625,8 @@ class FFmpegNormalize:
|
|
|
524
625
|
|
|
525
626
|
try:
|
|
526
627
|
media_file.run_normalization(batch_reference=batch_reference)
|
|
628
|
+
if self.verify and not self.dry_run:
|
|
629
|
+
media_file.verify_output()
|
|
527
630
|
except Exception as e:
|
|
528
631
|
media_file.status = "error"
|
|
529
632
|
media_file.error = str(e)
|
|
@@ -542,6 +645,8 @@ class FFmpegNormalize:
|
|
|
542
645
|
|
|
543
646
|
try:
|
|
544
647
|
media_file.run_normalization()
|
|
648
|
+
if self.verify and not self.dry_run:
|
|
649
|
+
media_file.verify_output()
|
|
545
650
|
except Exception as e:
|
|
546
651
|
media_file.status = "error"
|
|
547
652
|
media_file.error = str(e)
|
|
@@ -652,4 +757,23 @@ class FFmpegNormalize:
|
|
|
652
757
|
lines = [f"Summary ({header}):", "", fmt_line(headings)]
|
|
653
758
|
lines.append(fmt_line(["-" * w for w in widths]))
|
|
654
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.")
|
|
655
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
|
|
@@ -784,7 +889,10 @@ class MediaFile:
|
|
|
784
889
|
list[str]: the filters, in application order
|
|
785
890
|
"""
|
|
786
891
|
skip_normalization = False
|
|
787
|
-
if self.ffmpeg_normalize.lower_only
|
|
892
|
+
if self.ffmpeg_normalize.lower_only and not (
|
|
893
|
+
self.batch_reference is not None
|
|
894
|
+
and self.ffmpeg_normalize.normalization_type in ("peak", "rms")
|
|
895
|
+
):
|
|
788
896
|
if self.ffmpeg_normalize.normalization_type == "ebu":
|
|
789
897
|
if (
|
|
790
898
|
audio_stream.loudness_statistics["ebu_pass1"] is not None
|
|
@@ -9,8 +9,6 @@ import shutil
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from ._ffmpeg_normalize import FFmpegNormalize
|
|
13
|
-
|
|
14
12
|
_logger = logging.getLogger(__name__)
|
|
15
13
|
|
|
16
14
|
|
|
@@ -171,60 +169,23 @@ class PresetManager:
|
|
|
171
169
|
return preset_data
|
|
172
170
|
|
|
173
171
|
def merge_preset_with_args(
|
|
174
|
-
self, preset_data: dict[str, Any], cli_args: Any
|
|
172
|
+
self, preset_data: dict[str, Any], cli_args: Any, explicit_args: set[str]
|
|
175
173
|
) -> None:
|
|
176
|
-
"""
|
|
174
|
+
"""Apply preset values only to options omitted from the command line.
|
|
177
175
|
|
|
178
176
|
Args:
|
|
179
|
-
preset_data:
|
|
180
|
-
cli_args: Parsed
|
|
181
|
-
|
|
182
|
-
The CLI arguments take precedence over preset values. This function modifies
|
|
183
|
-
cli_args in place by setting attributes that were not explicitly provided
|
|
184
|
-
on the command line.
|
|
177
|
+
preset_data: Preset configuration.
|
|
178
|
+
cli_args: Parsed arguments, including defaults; modified in place.
|
|
179
|
+
explicit_args: Argument destinations explicitly supplied by the user.
|
|
185
180
|
"""
|
|
186
181
|
for key, value in preset_data.items():
|
|
187
|
-
# Convert hyphens to underscores to match argparse attribute names
|
|
188
182
|
attr_name = key.replace("-", "_")
|
|
189
|
-
|
|
190
|
-
# Check if this attribute exists in cli_args
|
|
191
183
|
if not hasattr(cli_args, attr_name):
|
|
192
184
|
_logger.warning(
|
|
193
185
|
f"Preset option '{key}' is not a valid ffmpeg-normalize option. Skipping."
|
|
194
186
|
)
|
|
195
187
|
continue
|
|
196
|
-
|
|
197
|
-
# Get the current value
|
|
198
|
-
current_value = getattr(cli_args, attr_name)
|
|
199
|
-
|
|
200
|
-
# Check if this was explicitly set by the user
|
|
201
|
-
# For most types, we can infer this by checking against the defaults:
|
|
202
|
-
# - None values were not explicitly set
|
|
203
|
-
# - Empty lists were not explicitly set
|
|
204
|
-
# - False boolean flags were not explicitly set
|
|
205
|
-
# - Default numeric values (specific to each option) need special handling
|
|
206
|
-
|
|
207
|
-
should_apply = False
|
|
208
|
-
|
|
209
|
-
if isinstance(value, bool):
|
|
210
|
-
# For boolean flags, apply preset only if currently False (not set)
|
|
211
|
-
should_apply = not current_value
|
|
212
|
-
elif isinstance(current_value, list):
|
|
213
|
-
# For lists (like output), apply preset only if empty
|
|
214
|
-
should_apply = not current_value
|
|
215
|
-
elif current_value is None:
|
|
216
|
-
# For None values, always apply preset (not explicitly set)
|
|
217
|
-
should_apply = True
|
|
218
|
-
else:
|
|
219
|
-
# For other values (numbers, strings), check if they match known defaults
|
|
220
|
-
# This is conservative: only override if the value is a known default
|
|
221
|
-
if (
|
|
222
|
-
attr_name in FFmpegNormalize.DEFAULTS
|
|
223
|
-
and current_value == FFmpegNormalize.DEFAULTS[attr_name]
|
|
224
|
-
):
|
|
225
|
-
should_apply = True
|
|
226
|
-
|
|
227
|
-
if should_apply:
|
|
188
|
+
if attr_name not in explicit_args:
|
|
228
189
|
setattr(cli_args, attr_name, value)
|
|
229
190
|
_logger.debug(f"Applied preset option '{key}' = {value}")
|
|
230
191
|
|
|
@@ -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
|
{ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.43.0 → ffmpeg_normalize-1.44.0}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|