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.
Files changed (17) hide show
  1. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/PKG-INFO +18 -1
  2. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/README.md +17 -0
  3. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/pyproject.toml +1 -1
  4. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/__main__.py +70 -10
  5. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_cmd_utils.py +38 -0
  6. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +30 -23
  7. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_media_file.py +175 -3
  8. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_streams.py +114 -2
  9. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/LICENSE.md +0 -0
  10. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/__init__.py +0 -0
  11. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_errors.py +0 -0
  12. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_logger.py +0 -0
  13. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/_presets.py +0 -0
  14. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  15. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  16. {ffmpeg_normalize-1.38.0 → ffmpeg_normalize-1.40.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  17. {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.38.0
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!
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.38.0"
7
+ version = "1.40.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- # group_normalization.add_argument(
226
- # '--threshold',
227
- # type=float,
228
- # help=textwrap.dedent("""\
229
- # Threshold below which normalization should not be run.
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
- # If the stream falls within the threshold, it will simply be copied.
232
- # """),
233
- # default=0.5
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.
@@ -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
- # threshold=0.5,
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
- # self.threshold = float(threshold)
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
- if len(self.media_files) > 1:
453
- _logger.error(
454
- f"Error analyzing input file {media_file}, will "
455
- f"continue batch-processing. Error was: {e}"
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
- if len(self.media_files) > 1:
481
- _logger.error(
482
- f"Error processing input file {media_file}, will "
483
- f"continue batch-processing. Error was: {e}"
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
- if len(self.media_files) > 1:
502
- _logger.error(
503
- f"Error processing input file {media_file}, will "
504
- f"continue batch-processing. Error was: {e}"
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(1)) if bit_depth_match else None
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 CommandRunner, dict_to_filter_opts
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 LoudnessStatisticsWithMetadata(LoudnessStatistics):
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