ffmpeg-normalize 1.38.0__tar.gz → 1.40.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.38.0 → ffmpeg_normalize-1.40.0}/PKG-INFO +18 -1
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/README.md +17 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/pyproject.toml +1 -1
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/__main__.py +70 -10
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_cmd_utils.py +38 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +30 -23
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_media_file.py +175 -3
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_streams.py +114 -2
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/LICENSE.md +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/__init__.py +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_errors.py +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_logger.py +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_presets.py +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
- {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.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.40.0
|
|
4
4
|
Summary: Normalize audio via ffmpeg
|
|
5
5
|
Keywords: ffmpeg,normalize,audio
|
|
6
6
|
Author: Werner Robitza
|
|
@@ -49,6 +49,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
49
49
|
- RMS-based normalization — Adjust audio to a specific RMS level
|
|
50
50
|
- Peak normalization — Adjust audio to a specific peak level
|
|
51
51
|
- Selective audio stream normalization — Normalize specific audio streams or only default streams
|
|
52
|
+
- Skip files already at target — Avoid re-encoding files already within a threshold of the target level
|
|
52
53
|
- Video file support — Process video files while preserving video streams
|
|
53
54
|
- Docker support — Run via Docker container
|
|
54
55
|
- Python API — Use programmatically in your Python projects
|
|
@@ -64,6 +65,22 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
64
65
|
|
|
65
66
|
## 🆕 What's New
|
|
66
67
|
|
|
68
|
+
- Version 1.40.0 can optionally **skip files that are already at the target level** via `--threshold` (e.g. `--threshold 0.5`, disabled by default). Such files are copied through unchanged instead of being re-encoded. The `--print-stats` output now includes a per-file `status` (`normalized`, `skipped`, or `error`, plus an `error` message on failure), and the exit code is non-zero if any file failed to process, so a script can tell what happened to each file.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
ffmpeg-normalize input.flac -nt peak -t 0 -c:a flac --print-stats -o output.flac
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Version 1.39.0 preserves the **input bit depth** by default when encoding to formats like FLAC, so 16-bit input stays 16-bit without needing `-e "-sample_fmt s16"`. Use `--no-keep-bit-depth` to opt out. It also adds `--keep-mtime` to copy the input file's modification time to the output, which is useful for preserving when a track was added to a music library.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ffmpeg-normalize input.flac -nt peak -t 0 -c:a flac --keep-mtime -o output.flac
|
|
82
|
+
```
|
|
83
|
+
|
|
67
84
|
- Version 1.38.0 writes the normalized output directly to the destination without using temporary files
|
|
68
85
|
|
|
69
86
|
- Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
|
|
@@ -18,6 +18,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
18
18
|
- RMS-based normalization — Adjust audio to a specific RMS level
|
|
19
19
|
- Peak normalization — Adjust audio to a specific peak level
|
|
20
20
|
- Selective audio stream normalization — Normalize specific audio streams or only default streams
|
|
21
|
+
- Skip files already at target — Avoid re-encoding files already within a threshold of the target level
|
|
21
22
|
- Video file support — Process video files while preserving video streams
|
|
22
23
|
- Docker support — Run via Docker container
|
|
23
24
|
- Python API — Use programmatically in your Python projects
|
|
@@ -33,6 +34,22 @@ This program normalizes media files to a certain loudness level using the EBU R1
|
|
|
33
34
|
|
|
34
35
|
## 🆕 What's New
|
|
35
36
|
|
|
37
|
+
- Version 1.40.0 can optionally **skip files that are already at the target level** via `--threshold` (e.g. `--threshold 0.5`, disabled by default). Such files are copied through unchanged instead of being re-encoded. The `--print-stats` output now includes a per-file `status` (`normalized`, `skipped`, or `error`, plus an `error` message on failure), and the exit code is non-zero if any file failed to process, so a script can tell what happened to each file.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
ffmpeg-normalize input.flac -nt peak -t 0 -c:a flac --print-stats -o output.flac
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Version 1.39.0 preserves the **input bit depth** by default when encoding to formats like FLAC, so 16-bit input stays 16-bit without needing `-e "-sample_fmt s16"`. Use `--no-keep-bit-depth` to opt out. It also adds `--keep-mtime` to copy the input file's modification time to the output, which is useful for preserving when a track was added to a music library.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
ffmpeg-normalize input.flac -nt peak -t 0 -c:a flac --keep-mtime -o output.flac
|
|
51
|
+
```
|
|
52
|
+
|
|
36
53
|
- Version 1.38.0 writes the normalized output directly to the destination without using temporary files
|
|
37
54
|
|
|
38
55
|
- Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
|
|
@@ -94,6 +94,18 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
94
94
|
),
|
|
95
95
|
default=FFmpegNormalize.DEFAULTS["output_folder"],
|
|
96
96
|
)
|
|
97
|
+
group_io.add_argument(
|
|
98
|
+
"--keep-mtime",
|
|
99
|
+
action="store_true",
|
|
100
|
+
help=textwrap.dedent(
|
|
101
|
+
"""\
|
|
102
|
+
Copy the input file's modification time to the output file.
|
|
103
|
+
|
|
104
|
+
Only the access and modification times are copied; a file's creation
|
|
105
|
+
time (tracked separately on some operating systems) is not affected.
|
|
106
|
+
"""
|
|
107
|
+
),
|
|
108
|
+
)
|
|
97
109
|
|
|
98
110
|
group_general = parser.add_argument_group("General Options")
|
|
99
111
|
group_general.add_argument(
|
|
@@ -222,16 +234,28 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
222
234
|
),
|
|
223
235
|
)
|
|
224
236
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
group_normalization.add_argument(
|
|
238
|
+
"--threshold",
|
|
239
|
+
type=float,
|
|
240
|
+
help=textwrap.dedent(
|
|
241
|
+
f"""\
|
|
242
|
+
Skip normalization when a file is already within this many dB/LU of the
|
|
243
|
+
target level (default: {FFmpegNormalize.DEFAULTS["threshold"]}, i.e. disabled).
|
|
244
|
+
|
|
245
|
+
When set to a positive value, a file whose measured level is within the
|
|
246
|
+
threshold of the target is considered already normalized and copied
|
|
247
|
+
through unchanged instead of being re-encoded. Its status is reported as
|
|
248
|
+
"skipped" in the `--print-stats` output.
|
|
230
249
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
For EBU normalization, the measured integrated loudness is compared to
|
|
251
|
+
the target level; for peak and RMS, the measured peak/RMS level is used.
|
|
252
|
+
|
|
253
|
+
The default of 0 always normalizes. Has no effect in batch or ReplayGain
|
|
254
|
+
mode, or when a pre/post filter or channel downmix is used.
|
|
255
|
+
"""
|
|
256
|
+
),
|
|
257
|
+
default=FFmpegNormalize.DEFAULTS["threshold"],
|
|
258
|
+
)
|
|
235
259
|
|
|
236
260
|
group_ebu = parser.add_argument_group("EBU R128 Normalization")
|
|
237
261
|
group_ebu.add_argument(
|
|
@@ -451,6 +475,26 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
451
475
|
action="store_true",
|
|
452
476
|
help="Copy original, non-normalized audio streams to output file",
|
|
453
477
|
)
|
|
478
|
+
group_acodec.add_argument(
|
|
479
|
+
"--keep-bit-depth",
|
|
480
|
+
action=argparse.BooleanOptionalAction,
|
|
481
|
+
default=FFmpegNormalize.DEFAULTS["keep_bit_depth"],
|
|
482
|
+
help=textwrap.dedent(
|
|
483
|
+
"""\
|
|
484
|
+
Carry the detected input bit depth through to the output encoder
|
|
485
|
+
(default: enabled).
|
|
486
|
+
|
|
487
|
+
By default, the matching output sample format is set for the chosen
|
|
488
|
+
encoder (e.g. FLAC), so you do not need to pass it via
|
|
489
|
+
`-e`/`--extra-output-options` yourself. Use `--no-keep-bit-depth` to let
|
|
490
|
+
the encoder pick its own sample format instead.
|
|
491
|
+
|
|
492
|
+
The chosen sample format is constrained to what the encoder supports.
|
|
493
|
+
ffmpeg has no 24-bit sample format, so 24-bit audio is carried in the
|
|
494
|
+
32-bit `s32` format. Floating-point sources are left to the encoder.
|
|
495
|
+
"""
|
|
496
|
+
),
|
|
497
|
+
)
|
|
454
498
|
group_acodec.add_argument(
|
|
455
499
|
"-prf",
|
|
456
500
|
"--pre-filter",
|
|
@@ -675,8 +719,8 @@ def main() -> None:
|
|
|
675
719
|
normalization_type=cli_args.normalization_type,
|
|
676
720
|
target_level=cli_args.target_level,
|
|
677
721
|
print_stats=cli_args.print_stats,
|
|
722
|
+
threshold=cli_args.threshold,
|
|
678
723
|
loudness_range_target=cli_args.loudness_range_target,
|
|
679
|
-
# threshold=cli_args.threshold,
|
|
680
724
|
keep_loudness_range_target=cli_args.keep_loudness_range_target,
|
|
681
725
|
keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
|
|
682
726
|
true_peak=cli_args.true_peak,
|
|
@@ -708,6 +752,8 @@ def main() -> None:
|
|
|
708
752
|
audio_streams=audio_streams,
|
|
709
753
|
audio_default_only=cli_args.audio_default_only,
|
|
710
754
|
keep_other_audio=cli_args.keep_other_audio,
|
|
755
|
+
keep_mtime=cli_args.keep_mtime,
|
|
756
|
+
keep_bit_depth=cli_args.keep_bit_depth,
|
|
711
757
|
)
|
|
712
758
|
|
|
713
759
|
if cli_args.output and len(cli_args.input) > len(cli_args.output):
|
|
@@ -792,6 +838,20 @@ def main() -> None:
|
|
|
792
838
|
except FFmpegNormalizeError as e:
|
|
793
839
|
error(e)
|
|
794
840
|
|
|
841
|
+
# Report per-file failures and exit non-zero if any file failed to process.
|
|
842
|
+
# Files that were skipped because they were already at the target level are
|
|
843
|
+
# not errors and do not affect the exit code.
|
|
844
|
+
failed_files = [
|
|
845
|
+
media_file
|
|
846
|
+
for media_file in ffmpeg_normalize.media_files
|
|
847
|
+
if media_file.status == "error"
|
|
848
|
+
]
|
|
849
|
+
if failed_files:
|
|
850
|
+
_logger.error(f"{len(failed_files)} file(s) failed to process:")
|
|
851
|
+
for media_file in failed_files:
|
|
852
|
+
_logger.error(f" - {media_file.input_file}: {media_file.error}")
|
|
853
|
+
sys.exit(1)
|
|
854
|
+
|
|
795
855
|
|
|
796
856
|
if __name__ == "__main__":
|
|
797
857
|
main()
|
|
@@ -204,6 +204,44 @@ def get_ffmpeg_exe() -> str:
|
|
|
204
204
|
return ff_path
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
_encoder_sample_formats_cache: dict[str, list[str]] = {}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_encoder_sample_formats(encoder: str) -> list[str]:
|
|
211
|
+
"""
|
|
212
|
+
Return the list of sample formats supported by an ffmpeg audio encoder.
|
|
213
|
+
|
|
214
|
+
The result is parsed from ``ffmpeg -h encoder=<encoder>`` and cached per
|
|
215
|
+
encoder for the lifetime of the process.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
encoder: Name of the ffmpeg audio encoder (e.g. "flac").
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
list[str]: Supported sample formats (e.g. ["s16", "s32"]), or an empty
|
|
222
|
+
list if they could not be determined.
|
|
223
|
+
"""
|
|
224
|
+
if encoder in _encoder_sample_formats_cache:
|
|
225
|
+
return _encoder_sample_formats_cache[encoder]
|
|
226
|
+
|
|
227
|
+
formats: list[str] = []
|
|
228
|
+
try:
|
|
229
|
+
output = (
|
|
230
|
+
CommandRunner()
|
|
231
|
+
.run_command([get_ffmpeg_exe(), "-hide_banner", "-h", f"encoder={encoder}"])
|
|
232
|
+
.get_output()
|
|
233
|
+
)
|
|
234
|
+
if match := re.search(r"Supported sample formats:\s*(.+)", output):
|
|
235
|
+
formats = match.group(1).split()
|
|
236
|
+
except (RuntimeError, FFmpegNormalizeError) as e:
|
|
237
|
+
_logger.debug(
|
|
238
|
+
f"Could not determine sample formats for encoder '{encoder}': {e}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
_encoder_sample_formats_cache[encoder] = formats
|
|
242
|
+
return formats
|
|
243
|
+
|
|
244
|
+
|
|
207
245
|
def ffmpeg_has_loudnorm() -> bool:
|
|
208
246
|
"""
|
|
209
247
|
Run feature detection on ffmpeg to see if it supports the loudnorm filter.
|
{ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py
RENAMED
|
@@ -55,6 +55,7 @@ class FFmpegNormalize:
|
|
|
55
55
|
normalization_type (str, optional): Normalization type. Defaults to "ebu".
|
|
56
56
|
target_level (float, optional): Target level. Defaults to -23.0.
|
|
57
57
|
print_stats (bool, optional): Print loudnorm stats. Defaults to False.
|
|
58
|
+
threshold (float, optional): When set to a positive value, skip normalization when the input is already within this many dB/LU of the target level, copying it through unchanged. Defaults to 0 (disabled, always normalize).
|
|
58
59
|
loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
|
|
59
60
|
keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
|
|
60
61
|
keep_lra_above_loudness_range_target (bool, optional): Keep input loudness range above loudness range target. Defaults to False.
|
|
@@ -88,6 +89,8 @@ class FFmpegNormalize:
|
|
|
88
89
|
audio_streams (list[int] | None, optional): List of audio stream indices to normalize. Defaults to None (all streams).
|
|
89
90
|
audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
|
|
90
91
|
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
|
|
92
|
+
keep_mtime (bool, optional): Copy the input file's modification time to the output file. Defaults to False.
|
|
93
|
+
keep_bit_depth (bool, optional): Carry the detected input bit depth through to the output encoder. Defaults to True.
|
|
91
94
|
|
|
92
95
|
Raises:
|
|
93
96
|
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
|
|
@@ -99,6 +102,7 @@ class FFmpegNormalize:
|
|
|
99
102
|
"normalization_type": "ebu",
|
|
100
103
|
"target_level": -23.0,
|
|
101
104
|
"print_stats": False,
|
|
105
|
+
"threshold": 0.0,
|
|
102
106
|
"loudness_range_target": 7.0,
|
|
103
107
|
"keep_loudness_range_target": False,
|
|
104
108
|
"keep_lra_above_loudness_range_target": False,
|
|
@@ -133,6 +137,8 @@ class FFmpegNormalize:
|
|
|
133
137
|
"audio_streams": None,
|
|
134
138
|
"audio_default_only": False,
|
|
135
139
|
"keep_other_audio": False,
|
|
140
|
+
"keep_mtime": False,
|
|
141
|
+
"keep_bit_depth": True,
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
def __init__(
|
|
@@ -140,7 +146,7 @@ class FFmpegNormalize:
|
|
|
140
146
|
normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
|
|
141
147
|
target_level: float = -23.0,
|
|
142
148
|
print_stats: bool = False,
|
|
143
|
-
|
|
149
|
+
threshold: float = 0.0,
|
|
144
150
|
loudness_range_target: float = 7.0,
|
|
145
151
|
keep_loudness_range_target: bool = False,
|
|
146
152
|
keep_lra_above_loudness_range_target: bool = False,
|
|
@@ -174,6 +180,8 @@ class FFmpegNormalize:
|
|
|
174
180
|
audio_streams: list[int] | None = None,
|
|
175
181
|
audio_default_only: bool = False,
|
|
176
182
|
keep_other_audio: bool = False,
|
|
183
|
+
keep_mtime: bool = False,
|
|
184
|
+
keep_bit_depth: bool = True,
|
|
177
185
|
):
|
|
178
186
|
self.ffmpeg_exe = get_ffmpeg_exe()
|
|
179
187
|
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
|
|
@@ -197,7 +205,7 @@ class FFmpegNormalize:
|
|
|
197
205
|
|
|
198
206
|
self.print_stats = print_stats
|
|
199
207
|
|
|
200
|
-
|
|
208
|
+
self.threshold = check_range(threshold, 0, 99, name="threshold")
|
|
201
209
|
|
|
202
210
|
self.loudness_range_target = check_range(
|
|
203
211
|
loudness_range_target, 1, 50, name="loudness_range_target"
|
|
@@ -263,6 +271,9 @@ class FFmpegNormalize:
|
|
|
263
271
|
self.audio_default_only = audio_default_only
|
|
264
272
|
self.keep_other_audio = keep_other_audio
|
|
265
273
|
|
|
274
|
+
self.keep_mtime = keep_mtime
|
|
275
|
+
self.keep_bit_depth = keep_bit_depth
|
|
276
|
+
|
|
266
277
|
if (
|
|
267
278
|
self.audio_codec is None or "pcm" in self.audio_codec
|
|
268
279
|
) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
|
|
@@ -449,13 +460,10 @@ class FFmpegNormalize:
|
|
|
449
460
|
"Dynamic EBU mode: First pass skipped for this file."
|
|
450
461
|
)
|
|
451
462
|
except Exception as e:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
)
|
|
457
|
-
else:
|
|
458
|
-
raise e
|
|
463
|
+
media_file.status = "error"
|
|
464
|
+
media_file.error = str(e)
|
|
465
|
+
_logger.error(f"Error analyzing input file {media_file}: {e}")
|
|
466
|
+
continue
|
|
459
467
|
|
|
460
468
|
# Phase 2: Calculate batch reference loudness
|
|
461
469
|
batch_reference = self._calculate_batch_reference()
|
|
@@ -470,6 +478,11 @@ class FFmpegNormalize:
|
|
|
470
478
|
position=0,
|
|
471
479
|
)
|
|
472
480
|
):
|
|
481
|
+
# Skip files that already failed during analysis
|
|
482
|
+
if media_file.status == "error":
|
|
483
|
+
_logger.debug(f"Skipping {media_file} because its analysis failed")
|
|
484
|
+
continue
|
|
485
|
+
|
|
473
486
|
_logger.info(
|
|
474
487
|
f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
|
|
475
488
|
)
|
|
@@ -477,13 +490,10 @@ class FFmpegNormalize:
|
|
|
477
490
|
try:
|
|
478
491
|
media_file.run_normalization(batch_reference=batch_reference)
|
|
479
492
|
except Exception as e:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
)
|
|
485
|
-
else:
|
|
486
|
-
raise e
|
|
493
|
+
media_file.status = "error"
|
|
494
|
+
media_file.error = str(e)
|
|
495
|
+
_logger.error(f"Error processing input file {media_file}: {e}")
|
|
496
|
+
continue
|
|
487
497
|
else:
|
|
488
498
|
# Non-batch mode: process each file completely before moving to the next
|
|
489
499
|
for index, media_file in enumerate(
|
|
@@ -498,13 +508,10 @@ class FFmpegNormalize:
|
|
|
498
508
|
try:
|
|
499
509
|
media_file.run_normalization()
|
|
500
510
|
except Exception as e:
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
)
|
|
506
|
-
else:
|
|
507
|
-
raise e
|
|
511
|
+
media_file.status = "error"
|
|
512
|
+
media_file.error = str(e)
|
|
513
|
+
_logger.error(f"Error processing input file {media_file}: {e}")
|
|
514
|
+
continue
|
|
508
515
|
|
|
509
516
|
if self.print_stats:
|
|
510
517
|
json.dump(
|
|
@@ -4,7 +4,7 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import shlex
|
|
7
|
-
from shutil import rmtree
|
|
7
|
+
from shutil import copyfile, rmtree
|
|
8
8
|
from tempfile import mkdtemp, mkstemp
|
|
9
9
|
from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict, Union
|
|
10
10
|
|
|
@@ -70,6 +70,12 @@ class MediaFile:
|
|
|
70
70
|
"""
|
|
71
71
|
self.ffmpeg_normalize = ffmpeg_normalize
|
|
72
72
|
self.skip = False
|
|
73
|
+
# Per-file outcome, reported in the --print-stats output: "normalized"
|
|
74
|
+
# (default), "skipped" (already within threshold of target), or "error".
|
|
75
|
+
# "error" is set by FFmpegNormalize.run_normalization when processing
|
|
76
|
+
# fails; on failure, self.error holds the error message.
|
|
77
|
+
self.status: str = "normalized"
|
|
78
|
+
self.error: str | None = None
|
|
73
79
|
self.input_file = input_file
|
|
74
80
|
self.output_file = output_file
|
|
75
81
|
current_ext = os.path.splitext(output_file)[1][1:]
|
|
@@ -87,6 +93,9 @@ class MediaFile:
|
|
|
87
93
|
self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
|
|
88
94
|
self.temp_file: Union[str, None] = None
|
|
89
95
|
self.batch_reference: float | None = None
|
|
96
|
+
# Input (access, modification) times captured before processing, used
|
|
97
|
+
# when the keep_mtime option is enabled.
|
|
98
|
+
self.input_timestamps: tuple[float, float] | None = None
|
|
90
99
|
|
|
91
100
|
self.parse_streams()
|
|
92
101
|
|
|
@@ -178,8 +187,9 @@ class MediaFile:
|
|
|
178
187
|
sample_rate = (
|
|
179
188
|
int(sample_rate_match.group(1)) if sample_rate_match else None
|
|
180
189
|
)
|
|
181
|
-
bit_depth_match = re.search(r"[sfu](\d+)(p|le|be)?", line)
|
|
182
|
-
bit_depth = int(bit_depth_match.group(
|
|
190
|
+
bit_depth_match = re.search(r"([sfu])(\d+)(p|le|be)?", line)
|
|
191
|
+
bit_depth = int(bit_depth_match.group(2)) if bit_depth_match else None
|
|
192
|
+
is_float = bit_depth_match.group(1) == "f" if bit_depth_match else False
|
|
183
193
|
self.streams["audio"][stream_id] = AudioStream(
|
|
184
194
|
self.ffmpeg_normalize,
|
|
185
195
|
self,
|
|
@@ -188,6 +198,7 @@ class MediaFile:
|
|
|
188
198
|
bit_depth,
|
|
189
199
|
duration,
|
|
190
200
|
is_default,
|
|
201
|
+
is_float,
|
|
191
202
|
)
|
|
192
203
|
|
|
193
204
|
elif "Video" in line:
|
|
@@ -280,6 +291,17 @@ class MediaFile:
|
|
|
280
291
|
# Store batch reference for use in second pass
|
|
281
292
|
self.batch_reference = batch_reference
|
|
282
293
|
|
|
294
|
+
# Capture the input file's timestamps before processing, since an
|
|
295
|
+
# in-place overwrite would otherwise lose the original modification time.
|
|
296
|
+
if self.ffmpeg_normalize.keep_mtime:
|
|
297
|
+
try:
|
|
298
|
+
stat = os.stat(self.input_file)
|
|
299
|
+
self.input_timestamps = (stat.st_atime, stat.st_mtime)
|
|
300
|
+
except OSError as e:
|
|
301
|
+
_logger.warning(
|
|
302
|
+
f"Could not read timestamps from {self.input_file}: {e}"
|
|
303
|
+
)
|
|
304
|
+
|
|
283
305
|
# run the first pass to get loudness stats, unless in dynamic EBU mode or batch mode
|
|
284
306
|
# (in batch mode, first pass is already done in FFmpegNormalize.run_normalization)
|
|
285
307
|
if batch_reference is None:
|
|
@@ -297,6 +319,14 @@ class MediaFile:
|
|
|
297
319
|
f"Batch mode: Skipping first pass (already completed), using batch reference = {batch_reference:.2f}"
|
|
298
320
|
)
|
|
299
321
|
|
|
322
|
+
# If the file is already within the configured threshold of the target
|
|
323
|
+
# level, skip normalization entirely and copy the input through to the
|
|
324
|
+
# output unchanged. This avoids needless re-encoding of files that are
|
|
325
|
+
# already at the target level.
|
|
326
|
+
if self._is_within_threshold():
|
|
327
|
+
self._handle_skip()
|
|
328
|
+
return
|
|
329
|
+
|
|
300
330
|
temp_dir = None
|
|
301
331
|
|
|
302
332
|
if self.ffmpeg_normalize.replaygain:
|
|
@@ -336,8 +366,141 @@ class MediaFile:
|
|
|
336
366
|
# Strip any existing ReplayGain tags from the output file
|
|
337
367
|
# since they are no longer accurate after normalization
|
|
338
368
|
self._strip_replaygain_tags(self.output_file)
|
|
369
|
+
# Copy input timestamps last, after any tag modifications, so the
|
|
370
|
+
# output ends up with the original modification time.
|
|
371
|
+
if self.ffmpeg_normalize.keep_mtime and not self.ffmpeg_normalize.dry_run:
|
|
372
|
+
self._apply_input_timestamps()
|
|
339
373
|
_logger.info(f"Normalized file written to {self.output_file}")
|
|
340
374
|
|
|
375
|
+
def _is_within_threshold(self) -> bool:
|
|
376
|
+
"""
|
|
377
|
+
Return whether every stream selected for normalization is already
|
|
378
|
+
within the configured threshold of the target level, meaning the file
|
|
379
|
+
can be copied through unchanged instead of being re-normalized.
|
|
380
|
+
|
|
381
|
+
The check compares the measured level against the absolute target level
|
|
382
|
+
(integrated loudness for EBU, peak/RMS level otherwise). It is disabled
|
|
383
|
+
when the threshold is zero or less, in batch mode (where files are
|
|
384
|
+
adjusted relative to a shared reference rather than an absolute target),
|
|
385
|
+
in ReplayGain mode (which only writes tags), when a pre/post filter or
|
|
386
|
+
channel downmix is requested (since these change the audio), and when
|
|
387
|
+
the output extension differs from the input (since the file is copied
|
|
388
|
+
verbatim, a container change would otherwise produce an invalid file).
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
bool: True if the file should be skipped, False otherwise.
|
|
392
|
+
"""
|
|
393
|
+
threshold = self.ffmpeg_normalize.threshold
|
|
394
|
+
if threshold <= 0:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
if self.ffmpeg_normalize.batch or self.ffmpeg_normalize.replaygain:
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
self.ffmpeg_normalize.pre_filter
|
|
402
|
+
or self.ffmpeg_normalize.post_filter
|
|
403
|
+
or self.ffmpeg_normalize.audio_channels
|
|
404
|
+
):
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
# A skipped file is copied verbatim, which only yields a valid file when
|
|
408
|
+
# the container stays the same. If the output extension differs, fall
|
|
409
|
+
# back to normal normalization so the requested format is produced.
|
|
410
|
+
input_ext = os.path.splitext(self.input_file)[1][1:].lower()
|
|
411
|
+
if input_ext != self.output_ext.lower():
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
norm_type = self.ffmpeg_normalize.normalization_type
|
|
415
|
+
target = self.ffmpeg_normalize.target_level
|
|
416
|
+
streams = self._get_streams_to_normalize()
|
|
417
|
+
if not streams:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
for stream in streams:
|
|
421
|
+
measured: float | None
|
|
422
|
+
if norm_type == "ebu":
|
|
423
|
+
ebu_stats = stream.loudness_statistics["ebu_pass1"]
|
|
424
|
+
if ebu_stats is None:
|
|
425
|
+
return False
|
|
426
|
+
measured = ebu_stats["input_i"]
|
|
427
|
+
elif norm_type == "peak":
|
|
428
|
+
measured = stream.loudness_statistics["max"]
|
|
429
|
+
else: # rms
|
|
430
|
+
measured = stream.loudness_statistics["mean"]
|
|
431
|
+
|
|
432
|
+
if measured is None:
|
|
433
|
+
return False
|
|
434
|
+
if abs(target - float(measured)) > threshold:
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def _handle_skip(self) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Mark this file as skipped (already at target) and copy the input
|
|
442
|
+
through to the output unchanged.
|
|
443
|
+
|
|
444
|
+
The input is copied verbatim (codec and other output options are not
|
|
445
|
+
applied to skipped files; use a threshold of 0 to always re-encode).
|
|
446
|
+
Stale ReplayGain tags are still stripped from the output, and the input
|
|
447
|
+
modification time is preserved if requested.
|
|
448
|
+
"""
|
|
449
|
+
self.status = "skipped"
|
|
450
|
+
_logger.info(
|
|
451
|
+
f"{self.input_file}: already within {self.ffmpeg_normalize.threshold} "
|
|
452
|
+
f"of target level {self.ffmpeg_normalize.target_level}, "
|
|
453
|
+
"skipping normalization"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if self.ffmpeg_normalize.dry_run:
|
|
457
|
+
_logger.warning("Dry run used, not actually copying the file")
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
if self.output_file == os.devnull:
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if os.path.realpath(self.input_file) != os.path.realpath(self.output_file):
|
|
464
|
+
try:
|
|
465
|
+
copyfile(self.input_file, self.output_file)
|
|
466
|
+
except OSError as e:
|
|
467
|
+
raise FFmpegNormalizeError(
|
|
468
|
+
f"Could not copy {self.input_file} to {self.output_file}: {e}"
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
_logger.debug(
|
|
472
|
+
"Output file is the same as the input file, leaving it unchanged"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Remove any existing ReplayGain tags from the output, matching the
|
|
476
|
+
# behavior of a normal run.
|
|
477
|
+
self._strip_replaygain_tags(self.output_file)
|
|
478
|
+
|
|
479
|
+
if self.ffmpeg_normalize.keep_mtime:
|
|
480
|
+
self._apply_input_timestamps()
|
|
481
|
+
|
|
482
|
+
_logger.info(f"Skipped file copied to {self.output_file}")
|
|
483
|
+
|
|
484
|
+
def _apply_input_timestamps(self) -> None:
|
|
485
|
+
"""
|
|
486
|
+
Copy the input file's access and modification times to the output file.
|
|
487
|
+
|
|
488
|
+
Used when the ``keep_mtime`` option is enabled. Only the access and
|
|
489
|
+
modification times are copied; a file's creation time (which some
|
|
490
|
+
operating systems such as Windows track separately) is not affected.
|
|
491
|
+
"""
|
|
492
|
+
if self.input_timestamps is None:
|
|
493
|
+
return
|
|
494
|
+
atime, mtime = self.input_timestamps
|
|
495
|
+
try:
|
|
496
|
+
os.utime(self.output_file, (atime, mtime))
|
|
497
|
+
_logger.debug(
|
|
498
|
+
f"Copied input timestamps to {self.output_file} "
|
|
499
|
+
f"(atime={atime}, mtime={mtime})"
|
|
500
|
+
)
|
|
501
|
+
except OSError as e:
|
|
502
|
+
_logger.warning(f"Could not copy timestamps to {self.output_file}: {e}")
|
|
503
|
+
|
|
341
504
|
def _run_replaygain(self) -> None:
|
|
342
505
|
"""
|
|
343
506
|
Run the replaygain process for this file.
|
|
@@ -806,6 +969,15 @@ class MediaFile:
|
|
|
806
969
|
for idx in range(len(streams_to_normalize)):
|
|
807
970
|
cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
|
|
808
971
|
|
|
972
|
+
# carry the input bit depth through to the output encoder, if requested
|
|
973
|
+
if self.ffmpeg_normalize.keep_bit_depth:
|
|
974
|
+
for idx, audio_stream in enumerate(streams_to_normalize):
|
|
975
|
+
sample_fmt = audio_stream.get_output_sample_fmt(
|
|
976
|
+
self.ffmpeg_normalize.audio_codec
|
|
977
|
+
)
|
|
978
|
+
if sample_fmt is not None:
|
|
979
|
+
cmd.extend([f"-sample_fmt:a:{idx}", sample_fmt])
|
|
980
|
+
|
|
809
981
|
# ... and subtitles
|
|
810
982
|
if not self.ffmpeg_normalize.subtitle_disable:
|
|
811
983
|
for s in self.streams["subtitle"].keys():
|
|
@@ -6,7 +6,11 @@ import os
|
|
|
6
6
|
import re
|
|
7
7
|
from typing import TYPE_CHECKING, Iterator, Literal, TypedDict, cast
|
|
8
8
|
|
|
9
|
-
from ._cmd_utils import
|
|
9
|
+
from ._cmd_utils import (
|
|
10
|
+
CommandRunner,
|
|
11
|
+
dict_to_filter_opts,
|
|
12
|
+
get_encoder_sample_formats,
|
|
13
|
+
)
|
|
10
14
|
from ._errors import FFmpegNormalizeError
|
|
11
15
|
|
|
12
16
|
if TYPE_CHECKING:
|
|
@@ -17,6 +21,23 @@ _logger = logging.getLogger(__name__)
|
|
|
17
21
|
|
|
18
22
|
_loudnorm_pattern = re.compile(r"\[Parsed_loudnorm_(\d+)")
|
|
19
23
|
|
|
24
|
+
# Maps ffmpeg sample formats to (bit size, is_float). Planar variants share the
|
|
25
|
+
# same characteristics as their packed counterparts.
|
|
26
|
+
_SAMPLE_FMT_INFO: dict[str, tuple[int, bool]] = {
|
|
27
|
+
"u8": (8, False),
|
|
28
|
+
"u8p": (8, False),
|
|
29
|
+
"s16": (16, False),
|
|
30
|
+
"s16p": (16, False),
|
|
31
|
+
"s32": (32, False),
|
|
32
|
+
"s32p": (32, False),
|
|
33
|
+
"s64": (64, False),
|
|
34
|
+
"s64p": (64, False),
|
|
35
|
+
"flt": (32, True),
|
|
36
|
+
"fltp": (32, True),
|
|
37
|
+
"dbl": (64, True),
|
|
38
|
+
"dblp": (64, True),
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
|
|
21
42
|
class EbuLoudnessStatistics(TypedDict):
|
|
22
43
|
input_i: float
|
|
@@ -38,10 +59,15 @@ class LoudnessStatistics(TypedDict):
|
|
|
38
59
|
max: float | None
|
|
39
60
|
|
|
40
61
|
|
|
41
|
-
class
|
|
62
|
+
class _OptionalStatisticsMetadata(TypedDict, total=False):
|
|
63
|
+
error: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoudnessStatisticsWithMetadata(LoudnessStatistics, _OptionalStatisticsMetadata):
|
|
42
67
|
input_file: str
|
|
43
68
|
output_file: str
|
|
44
69
|
stream_id: int
|
|
70
|
+
status: str
|
|
45
71
|
|
|
46
72
|
|
|
47
73
|
class MediaStream:
|
|
@@ -100,6 +126,7 @@ class AudioStream(MediaStream):
|
|
|
100
126
|
bit_depth: int | None,
|
|
101
127
|
duration: float | None,
|
|
102
128
|
is_default: bool = False,
|
|
129
|
+
is_float: bool = False,
|
|
103
130
|
):
|
|
104
131
|
"""
|
|
105
132
|
Create an AudioStream object.
|
|
@@ -112,6 +139,7 @@ class AudioStream(MediaStream):
|
|
|
112
139
|
bit_depth (int): bit depth in bits
|
|
113
140
|
duration (float): duration in seconds
|
|
114
141
|
is_default (bool): Whether this stream has the default disposition flag
|
|
142
|
+
is_float (bool): Whether the stream uses a floating-point sample format
|
|
115
143
|
"""
|
|
116
144
|
super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
|
|
117
145
|
|
|
@@ -127,6 +155,7 @@ class AudioStream(MediaStream):
|
|
|
127
155
|
|
|
128
156
|
self.duration = duration
|
|
129
157
|
self.is_default = is_default
|
|
158
|
+
self.is_float = is_float
|
|
130
159
|
|
|
131
160
|
@staticmethod
|
|
132
161
|
def _constrain(
|
|
@@ -171,7 +200,12 @@ class AudioStream(MediaStream):
|
|
|
171
200
|
"ebu_pass2": self.loudness_statistics["ebu_pass2"],
|
|
172
201
|
"mean": self.loudness_statistics["mean"],
|
|
173
202
|
"max": self.loudness_statistics["max"],
|
|
203
|
+
"status": self.media_file.status,
|
|
174
204
|
}
|
|
205
|
+
# Only present when the file failed to process, per the per-file outcome
|
|
206
|
+
# reporting (status is "error").
|
|
207
|
+
if self.media_file.error is not None:
|
|
208
|
+
stats["error"] = self.media_file.error
|
|
175
209
|
return stats
|
|
176
210
|
|
|
177
211
|
def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
|
|
@@ -205,6 +239,84 @@ class AudioStream(MediaStream):
|
|
|
205
239
|
)
|
|
206
240
|
return "pcm_s16le"
|
|
207
241
|
|
|
242
|
+
def get_output_sample_fmt(self, codec: str | None) -> str | None:
|
|
243
|
+
"""
|
|
244
|
+
Choose an output sample format for the given encoder that preserves the
|
|
245
|
+
detected input bit depth as closely as possible.
|
|
246
|
+
|
|
247
|
+
Used by the ``keep_bit_depth`` option (enabled by default) so that
|
|
248
|
+
encoders such as FLAC, which would otherwise pick their own default
|
|
249
|
+
sample format, retain the input bit depth. Only integer formats are
|
|
250
|
+
considered, and floating-point sources are left to the encoder, since
|
|
251
|
+
keeping bit depth is only meaningful for integer PCM sources.
|
|
252
|
+
|
|
253
|
+
Note that ffmpeg has no 24-bit sample format; 24-bit audio is carried in
|
|
254
|
+
the 32-bit ``s32`` format, and the encoder stores it accordingly.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
codec: The output audio codec name, or None for the PCM default.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
str | None: The chosen sample format, or None if none should be set
|
|
261
|
+
(unknown bit depth, floating-point source, no explicit codec, no
|
|
262
|
+
encoder info, or an encoder without integer sample formats). In
|
|
263
|
+
all of these cases the encoder default is used.
|
|
264
|
+
"""
|
|
265
|
+
if not self.bit_depth:
|
|
266
|
+
_logger.debug(
|
|
267
|
+
f"{self.media_file.input_file}: Could not determine input bit depth "
|
|
268
|
+
f"for stream {self.stream_id}; leaving the sample format to the encoder."
|
|
269
|
+
)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
if self.is_float:
|
|
273
|
+
# Pinning an integer sample format would silently convert a
|
|
274
|
+
# floating-point source to integer, so leave it to the encoder.
|
|
275
|
+
_logger.debug(
|
|
276
|
+
f"{self.media_file.input_file}: Stream {self.stream_id} is "
|
|
277
|
+
"floating-point; leaving the sample format to the encoder."
|
|
278
|
+
)
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
if codec is None:
|
|
282
|
+
# The PCM default path already derives the bit depth from the input
|
|
283
|
+
# via get_pcm_codec(), so there is nothing to set here.
|
|
284
|
+
_logger.debug(
|
|
285
|
+
"keep_bit_depth has no effect for the default PCM output; the "
|
|
286
|
+
"input bit depth is already preserved."
|
|
287
|
+
)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
supported = get_encoder_sample_formats(codec)
|
|
291
|
+
if not supported:
|
|
292
|
+
_logger.debug(
|
|
293
|
+
f"Could not determine supported sample formats for codec '{codec}'; "
|
|
294
|
+
"not setting an explicit sample format."
|
|
295
|
+
)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
# Only consider integer formats, since keeping bit depth is meaningful
|
|
299
|
+
# for integer PCM sources (e.g. FLAC, ALAC).
|
|
300
|
+
int_formats = [
|
|
301
|
+
fmt
|
|
302
|
+
for fmt in supported
|
|
303
|
+
if fmt in _SAMPLE_FMT_INFO and not _SAMPLE_FMT_INFO[fmt][1]
|
|
304
|
+
]
|
|
305
|
+
if not int_formats:
|
|
306
|
+
_logger.debug(
|
|
307
|
+
f"Encoder '{codec}' supports no integer sample formats; "
|
|
308
|
+
"leaving the sample format to the encoder."
|
|
309
|
+
)
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
# Prefer the smallest integer format that holds at least the input bit
|
|
313
|
+
# depth; fall back to the largest available if none is big enough.
|
|
314
|
+
candidates = sorted(int_formats, key=lambda fmt: _SAMPLE_FMT_INFO[fmt][0])
|
|
315
|
+
for fmt in candidates:
|
|
316
|
+
if _SAMPLE_FMT_INFO[fmt][0] >= self.bit_depth:
|
|
317
|
+
return fmt
|
|
318
|
+
return candidates[-1]
|
|
319
|
+
|
|
208
320
|
def _get_filter_str_with_pre_filter(self, current_filter: str) -> str:
|
|
209
321
|
"""
|
|
210
322
|
Get a filter string for current_filter, with the pre-filter
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/music.json
RENAMED
|
File without changes
|
{ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/podcast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|