ffmpeg-normalize 1.40.0__tar.gz → 1.41.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.40.0 → ffmpeg_normalize-1.41.0}/PKG-INFO +3 -1
  2. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/README.md +2 -0
  3. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/pyproject.toml +1 -1
  4. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/__main__.py +4 -1
  5. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_cmd_utils.py +129 -0
  6. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_ffmpeg_normalize.py +39 -18
  7. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_media_file.py +49 -9
  8. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/LICENSE.md +0 -0
  9. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/__init__.py +0 -0
  10. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_errors.py +0 -0
  11. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_logger.py +0 -0
  12. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_presets.py +0 -0
  13. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/_streams.py +0 -0
  14. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  15. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  16. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.0}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  17. {ffmpeg_normalize-1.40.0 → ffmpeg_normalize-1.41.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.40.0
3
+ Version: 1.41.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -65,6 +65,8 @@ This program normalizes media files to a certain loudness level using the EBU R1
65
65
 
66
66
  ## 🆕 What's New
67
67
 
68
+ - Version 1.41.0 automatically picks the correct output audio codec for the output container, so you no longer need to specify `-c:a`/`--audio-codec` unless you want to override the default. PCM is chosen for containers that support it; others will use teh default that ffmpeg picks. See [the usage guide](https://slhck.info/ffmpeg-normalize/usage/file-input-output/#how-the-output-audio-codec-is-chosen) for details.
69
+
68
70
  - 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
71
 
70
72
  Example:
@@ -34,6 +34,8 @@ This program normalizes media files to a certain loudness level using the EBU R1
34
34
 
35
35
  ## 🆕 What's New
36
36
 
37
+ - Version 1.41.0 automatically picks the correct output audio codec for the output container, so you no longer need to specify `-c:a`/`--audio-codec` unless you want to override the default. PCM is chosen for containers that support it; others will use teh default that ffmpeg picks. See [the usage guide](https://slhck.info/ffmpeg-normalize/usage/file-input-output/#how-the-output-audio-codec-is-chosen) for details.
38
+
37
39
  - 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
40
 
39
41
  Example:
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.40.0"
7
+ version = "1.41.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -429,7 +429,10 @@ def create_parser() -> argparse.ArgumentParser:
429
429
  Audio codec to use for output files.
430
430
  See `ffmpeg -encoders` for a list.
431
431
 
432
- Will use PCM audio with input stream bit depth by default.
432
+ Will use PCM audio with input stream bit depth by default. For output
433
+ containers that cannot store PCM (e.g. MP3, FLAC, Opus), the codec that
434
+ ffmpeg uses by default for that container is chosen automatically, so
435
+ you usually do not need to set this.
433
436
  """
434
437
  ),
435
438
  )
@@ -44,6 +44,27 @@ DUR_REGEX = re.compile(
44
44
  r"Duration: (?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})\.(?P<ms>\d{2})"
45
45
  )
46
46
 
47
+ # Containers that cannot store raw PCM audio. For these, the bit-depth-aware PCM
48
+ # default cannot be used, so a real audio codec has to be chosen (either via the
49
+ # -c:a option or by falling back to ffmpeg's own default for the container, see
50
+ # get_muxer_default_audio_encoder()). PCM_INCOMPATIBLE_FORMATS is matched against
51
+ # the -f/--output-format value, PCM_INCOMPATIBLE_EXTS against the output file
52
+ # extension (which additionally lists m4a).
53
+ PCM_INCOMPATIBLE_FORMATS = {"flac", "mp3", "mp4", "ogg", "oga", "opus", "webm"}
54
+ PCM_INCOMPATIBLE_EXTS = {"flac", "mp3", "mp4", "m4a", "ogg", "oga", "opus", "webm"}
55
+
56
+ # Output extensions whose ffmpeg muxer is registered under a different name.
57
+ # ffmpeg's av_guess_format() resolves these internally; for the "-h muxer="
58
+ # query in get_muxer_default_audio_encoder() we need the muxer's own name.
59
+ _MUXER_NAME_FOR_EXT = {
60
+ "m4a": "ipod",
61
+ "m4b": "ipod",
62
+ "m4v": "ipod",
63
+ "aac": "adts",
64
+ "mka": "matroska",
65
+ "mkv": "matroska",
66
+ }
67
+
47
68
 
48
69
  class CommandRunner:
49
70
  """
@@ -242,6 +263,114 @@ def get_encoder_sample_formats(encoder: str) -> list[str]:
242
263
  return formats
243
264
 
244
265
 
266
+ _codec_encoders_cache: dict[str, list[str]] | None = None
267
+ _muxer_default_encoder_cache: dict[str, str | None] = {}
268
+
269
+
270
+ def _get_codec_encoders() -> dict[str, list[str]]:
271
+ """
272
+ Map each ffmpeg codec name to its available encoders.
273
+
274
+ Parsed once from ``ffmpeg -codecs`` and cached for the process lifetime. A
275
+ codec with no explicit ``(encoders: ...)`` list uses an encoder of the same
276
+ name, so it maps to an empty list here.
277
+
278
+ Returns:
279
+ dict[str, list[str]]: Mapping of codec name to encoder names.
280
+ """
281
+ global _codec_encoders_cache
282
+ if _codec_encoders_cache is not None:
283
+ return _codec_encoders_cache
284
+
285
+ encoders: dict[str, list[str]] = {}
286
+ try:
287
+ output = (
288
+ CommandRunner()
289
+ .run_command([get_ffmpeg_exe(), "-hide_banner", "-codecs"])
290
+ .get_output()
291
+ )
292
+ for line in output.splitlines():
293
+ # Rows look like: " DEAIL. opus Opus ... (encoders: opus libopus)"
294
+ match = re.match(r"\s*[D.][E.][AVS.][I.][L.][S.]\s+([A-Za-z0-9_]+)", line)
295
+ if not match:
296
+ continue
297
+ enc_match = re.search(r"\(encoders:([^)]*)\)", line)
298
+ encoders[match.group(1)] = enc_match.group(1).split() if enc_match else []
299
+ except (RuntimeError, FFmpegNormalizeError) as e:
300
+ _logger.debug(f"Could not list ffmpeg codecs: {e}")
301
+
302
+ _codec_encoders_cache = encoders
303
+ return encoders
304
+
305
+
306
+ def _resolve_encoder_for_codec(codec_id: str) -> str:
307
+ """
308
+ Return the encoder ffmpeg uses by default for a codec id.
309
+
310
+ This mirrors ffmpeg's own selection when no encoder is given on the command
311
+ line: the external library wrapper (e.g. ``libopus``) is preferred over an
312
+ experimental native encoder of the same name (``opus``). Codecs without an
313
+ explicit encoder list use an encoder named like the codec itself.
314
+
315
+ Args:
316
+ codec_id: The codec id as reported by ffmpeg (e.g. "opus", "flac").
317
+
318
+ Returns:
319
+ str: The encoder name to pass to ``-c:a``.
320
+ """
321
+ encoders = _get_codec_encoders().get(codec_id, [])
322
+ lib_variant = f"lib{codec_id}"
323
+ if lib_variant in encoders:
324
+ return lib_variant
325
+ if encoders:
326
+ return encoders[0]
327
+ return codec_id
328
+
329
+
330
+ def get_muxer_default_audio_encoder(container: str) -> str | None:
331
+ """
332
+ Return the audio encoder ffmpeg would use by default for a container.
333
+
334
+ This replicates ffmpeg's own per-container choice when no ``-c:a`` is given,
335
+ rather than hardcoding a table, so it tracks whatever the installed ffmpeg
336
+ does (e.g. ``.ogg`` may default to flac on some builds). The container's
337
+ default audio codec is read from ``ffmpeg -h muxer=<name>`` and then mapped
338
+ to a concrete, non-experimental encoder via _resolve_encoder_for_codec().
339
+
340
+ Results are cached per container for the process lifetime, so batch runs only
341
+ query ffmpeg once per container.
342
+
343
+ Args:
344
+ container: Output file extension or ffmpeg format name without a leading
345
+ dot (e.g. "flac", "m4a", "ipod").
346
+
347
+ Returns:
348
+ str | None: The encoder name (e.g. "flac", "libopus"), or None if it
349
+ could not be determined.
350
+ """
351
+ container = container.lower()
352
+ if container in _muxer_default_encoder_cache:
353
+ return _muxer_default_encoder_cache[container]
354
+
355
+ muxer = _MUXER_NAME_FOR_EXT.get(container, container)
356
+ encoder: str | None = None
357
+ try:
358
+ output = (
359
+ CommandRunner()
360
+ .run_command([get_ffmpeg_exe(), "-hide_banner", "-h", f"muxer={muxer}"])
361
+ .get_output()
362
+ )
363
+ if match := re.search(r"Default audio codec:\s*([A-Za-z0-9_]+)", output):
364
+ encoder = _resolve_encoder_for_codec(match.group(1))
365
+ except (RuntimeError, FFmpegNormalizeError) as e:
366
+ _logger.debug(
367
+ f"Could not determine default audio codec for container '{container}': {e}"
368
+ )
369
+
370
+ _muxer_default_encoder_cache[container] = encoder
371
+ return encoder
372
+
373
+
245
374
  def ffmpeg_has_loudnorm() -> bool:
246
375
  """
247
376
  Run feature detection on ffmpeg to see if it supports the loudnorm filter.
@@ -9,7 +9,14 @@ from typing import TYPE_CHECKING, Literal
9
9
 
10
10
  from tqdm import tqdm
11
11
 
12
- from ._cmd_utils import ffmpeg_has_loudnorm, get_ffmpeg_exe, validate_input_file
12
+ from ._cmd_utils import (
13
+ PCM_INCOMPATIBLE_EXTS,
14
+ PCM_INCOMPATIBLE_FORMATS,
15
+ ffmpeg_has_loudnorm,
16
+ get_ffmpeg_exe,
17
+ get_muxer_default_audio_encoder,
18
+ validate_input_file,
19
+ )
13
20
  from ._errors import FFmpegNormalizeError
14
21
  from ._media_file import MediaFile
15
22
 
@@ -19,8 +26,6 @@ if TYPE_CHECKING:
19
26
  _logger = logging.getLogger(__name__)
20
27
 
21
28
  NORMALIZATION_TYPES = ("ebu", "rms", "peak")
22
- PCM_INCOMPATIBLE_FORMATS = {"flac", "mp3", "mp4", "ogg", "oga", "opus", "webm"}
23
- PCM_INCOMPATIBLE_EXTS = {"flac", "mp3", "mp4", "m4a", "ogg", "oga", "opus", "webm"}
24
29
 
25
30
 
26
31
  def check_range(number: object, min_r: float, max_r: float, name: str = "") -> float:
@@ -274,13 +279,21 @@ class FFmpegNormalize:
274
279
  self.keep_mtime = keep_mtime
275
280
  self.keep_bit_depth = keep_bit_depth
276
281
 
277
- if (
278
- self.audio_codec is None or "pcm" in self.audio_codec
279
- ) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
280
- raise FFmpegNormalizeError(
281
- f"Output format {self.output_format} does not support PCM audio. "
282
- "Please choose a suitable audio codec with the -c:a option."
283
- )
282
+ if self.output_format in PCM_INCOMPATIBLE_FORMATS:
283
+ if self.audio_codec is not None and "pcm" in self.audio_codec:
284
+ raise FFmpegNormalizeError(
285
+ f"Output format {self.output_format} does not support PCM audio. "
286
+ "Please choose a suitable audio codec with the -c:a option."
287
+ )
288
+ if (
289
+ self.audio_codec is None
290
+ and get_muxer_default_audio_encoder(self.output_format) is None
291
+ ):
292
+ raise FFmpegNormalizeError(
293
+ f"Output format {self.output_format} does not support PCM audio, "
294
+ "and a default audio codec could not be determined. Please choose "
295
+ "a suitable audio codec with the -c:a option."
296
+ )
284
297
 
285
298
  # replaygain only works for EBU for now
286
299
  if self.replaygain and self.normalization_type != "ebu":
@@ -316,14 +329,22 @@ class FFmpegNormalize:
316
329
  if not os.path.exists(input_file):
317
330
  raise FFmpegNormalizeError(f"file {input_file} does not exist")
318
331
 
319
- ext = os.path.splitext(output_file)[1][1:]
320
- if (
321
- self.audio_codec is None or "pcm" in self.audio_codec
322
- ) and ext in PCM_INCOMPATIBLE_EXTS:
323
- raise FFmpegNormalizeError(
324
- f"Output extension {ext} does not support PCM audio. "
325
- "Please choose a suitable audio codec with the -c:a option."
326
- )
332
+ ext = os.path.splitext(output_file)[1][1:].lower()
333
+ if ext in PCM_INCOMPATIBLE_EXTS:
334
+ if self.audio_codec is not None and "pcm" in self.audio_codec:
335
+ raise FFmpegNormalizeError(
336
+ f"Output extension {ext} does not support PCM audio. "
337
+ "Please choose a suitable audio codec with the -c:a option."
338
+ )
339
+ if (
340
+ self.audio_codec is None
341
+ and get_muxer_default_audio_encoder(ext) is None
342
+ ):
343
+ raise FFmpegNormalizeError(
344
+ f"Output extension {ext} does not support PCM audio, and a "
345
+ "default audio codec could not be determined. Please choose a "
346
+ "suitable audio codec with the -c:a option."
347
+ )
327
348
 
328
349
  self.media_files.append(MediaFile(self, input_file, output_file))
329
350
  self.file_count += 1
@@ -15,7 +15,13 @@ from mutagen.oggopus import OggOpus
15
15
  from mutagen.oggvorbis import OggVorbis
16
16
  from tqdm import tqdm
17
17
 
18
- from ._cmd_utils import DUR_REGEX, CommandRunner
18
+ from ._cmd_utils import (
19
+ DUR_REGEX,
20
+ PCM_INCOMPATIBLE_EXTS,
21
+ PCM_INCOMPATIBLE_FORMATS,
22
+ CommandRunner,
23
+ get_muxer_default_audio_encoder,
24
+ )
19
25
  from ._errors import FFmpegNormalizeError
20
26
  from ._streams import (
21
27
  AudioStream,
@@ -842,6 +848,40 @@ class MediaFile:
842
848
 
843
849
  return filter_complex_cmd, output_labels
844
850
 
851
+ def _get_audio_codec(self) -> str | None:
852
+ """
853
+ Return the audio encoder to use for normalized streams, or None to use
854
+ the bit-depth-aware PCM default.
855
+
856
+ An explicit ``--audio-codec`` (``-c:a``) always takes precedence.
857
+ Otherwise, for a container that cannot store PCM (e.g. FLAC, MP3, Opus),
858
+ fall back to the codec ffmpeg itself would default to for that container,
859
+ so audio-only outputs work without requiring ``-c:a``. PCM-friendly
860
+ containers (e.g. WAV, Matroska) keep the lossless PCM default regardless
861
+ of their own muxer default.
862
+
863
+ Returns:
864
+ str | None: The encoder name, or None to use the PCM default.
865
+ """
866
+ if self.ffmpeg_normalize.audio_codec:
867
+ return self.ffmpeg_normalize.audio_codec
868
+
869
+ output_format = self.ffmpeg_normalize.output_format
870
+ if output_format in PCM_INCOMPATIBLE_FORMATS:
871
+ container = output_format
872
+ elif self.output_ext.lower() in PCM_INCOMPATIBLE_EXTS:
873
+ container = self.output_ext
874
+ else:
875
+ return None
876
+
877
+ encoder = get_muxer_default_audio_encoder(container)
878
+ if encoder is not None:
879
+ _logger.debug(
880
+ f"No audio codec set; using ffmpeg's default '{encoder}' for "
881
+ f"container '{container}'"
882
+ )
883
+ return encoder
884
+
845
885
  def _second_pass(self) -> Iterator[float]:
846
886
  """
847
887
  Construct the second pass command and run it.
@@ -934,12 +974,14 @@ class MediaFile:
934
974
  # Track output audio stream index for codec assignment
935
975
  output_audio_idx = 0
936
976
 
977
+ # Resolve the audio codec once: an explicit --audio-codec wins, otherwise
978
+ # fall back to ffmpeg's own default codec for the output container (None
979
+ # means use the bit-depth-aware PCM default).
980
+ audio_codec = self._get_audio_codec()
981
+
937
982
  # set audio codec for normalized streams
938
983
  for audio_stream in streams_to_normalize:
939
- if self.ffmpeg_normalize.audio_codec:
940
- codec = self.ffmpeg_normalize.audio_codec
941
- else:
942
- codec = audio_stream.get_pcm_codec()
984
+ codec = audio_codec if audio_codec else audio_stream.get_pcm_codec()
943
985
  cmd.extend([f"-c:a:{output_audio_idx}", codec])
944
986
  output_audio_idx += 1
945
987
 
@@ -950,7 +992,7 @@ class MediaFile:
950
992
 
951
993
  # other audio options (if any) - only apply to normalized streams
952
994
  if self.ffmpeg_normalize.audio_bitrate:
953
- if self.ffmpeg_normalize.audio_codec == "libvorbis":
995
+ if audio_codec == "libvorbis":
954
996
  # libvorbis takes just a "-b" option, for some reason
955
997
  # https://github.com/slhck/ffmpeg-normalize/issues/277
956
998
  cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
@@ -972,9 +1014,7 @@ class MediaFile:
972
1014
  # carry the input bit depth through to the output encoder, if requested
973
1015
  if self.ffmpeg_normalize.keep_bit_depth:
974
1016
  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
- )
1017
+ sample_fmt = audio_stream.get_output_sample_fmt(audio_codec)
978
1018
  if sample_fmt is not None:
979
1019
  cmd.extend([f"-sample_fmt:a:{idx}", sample_fmt])
980
1020