Skip to content

Commit 0ec63af

Browse files
committed
feat: implement automatic audio codec selection based on output container
This is based on ffmpeg's default choice (a shell call to ffmpeg parses it), and still picks PCM for containers that support it to prevent quality loss.
1 parent ce93c4a commit 0ec63af

10 files changed

Lines changed: 303 additions & 34 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ This program normalizes media files to a certain loudness level using the EBU R1
3434

3535
## 🆕 What's New
3636

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+
3739
- 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.
3840

3941
Example:

docs/advanced/faq.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ If you decide to run `ffmpeg-normalize` with the default options, it will encode
3636

3737
I chose MKV as a default output container since it handles almost every possible combination of audio, video, and subtitle codecs. If you know which audio/video codec you want, and which container is supported, use the output options to specify the encoder and output file name manually.
3838

39+
## Which audio codec is used for my output?
40+
41+
If you do not set `-c:a`/`--audio-codec`, the codec is chosen for you based on the output container: PCM (lossless) for containers that support it (such as WAV or MKV), or — for containers that cannot store PCM, like MP3, MP4, FLAC, or Opus — the same default codec your ffmpeg would use for that container (e.g. `.mp3` → MP3, `.m4a` → AAC, `.flac` → FLAC, `.opus` → Opus).
42+
43+
See also: [How the output audio codec is chosen](../usage/file-input-output.md#how-the-output-audio-codec-is-chosen).
44+
3945
## I get a "Could not write header for output file" error
4046

4147
See the [next section](#the-conversion-does-not-work-and-i-get-a-cryptic-ffmpeg-error).
@@ -54,7 +60,7 @@ One possible reason is that the input file contains some streams that cannot be
5460

5561
- You are trying to normalize a movie file, writing to a `.wav` or `.mp3` file. WAV/MP3 files only support audio, not video. Disable video and subtitles with `-vn` and `-sn`, or choose a container that supports video (e.g. `.mkv`).
5662

57-
- You are trying to normalize a file, writing to an `.mp4` container. This program defaults to PCM audio, but MP4 does not support PCM audio. Make sure that your audio codec is set to something MP4 containers support (e.g. `-c:a aac`).
63+
- You explicitly requested PCM audio (e.g. `-c:a pcm_s16le`) while writing to a container that cannot store PCM, such as `.mp4`, `.flac`, or `.opus`. Either drop the `-c:a` option, in which case a suitable codec is [chosen automatically](../usage/file-input-output.md#how-the-output-audio-codec-is-chosen), or pick a codec the container supports (e.g. `-c:a aac` for MP4).
5864

5965
The default output container is `.mkv` as it will support most input stream types. If you want a different output container, [make sure that it supports](https://en.wikipedia.org/wiki/Comparison_of_container_file_formats) your input file's video, audio, and subtitle streams (if any).
6066

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Batch processing of several input files is possible, including video files.
3333

3434
## 🆕 What's New
3535

36+
- 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.
37+
3638
- 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.
3739

3840
Example:

docs/usage/cli-options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ Audio codec to use for output files.
218218

219219
See `ffmpeg -encoders` for a list.
220220

221-
Will use PCM audio with input stream bit depth by default.
221+
Will use PCM audio with input stream bit depth by default. For output containers that cannot store PCM (e.g. MP3, FLAC, Opus), the codec that ffmpeg uses by default for that container is chosen automatically, so you usually do not need to set this.
222222

223223
### `-b:a AUDIO_BITRATE, --audio-bitrate AUDIO_BITRATE`
224224

docs/usage/file-input-output.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,25 @@ for %i in (*.mkv) do ffmpeg-normalize "%i" -c:a aac -b:a 192k
7373

7474
CMD loops run `ffmpeg-normalize` once per file. This means `--batch` mode will **not** work, because each invocation only sees a single file. Use one of the methods above if you need batch normalization.
7575

76+
## How the output audio codec is chosen
77+
78+
When you do not pass `-c:a`/`--audio-codec`, `ffmpeg-normalize` picks the audio codec for you, based on the output container:
79+
80+
- For containers that can store uncompressed audio (such as WAV, MKV, or MOV), it uses **PCM** audio, matching the input bit depth. This is lossless, but produces large files.
81+
- For containers that cannot store PCM (MP3, MP4/M4A, FLAC, Ogg, Opus, WebM), it uses the **same default codec that ffmpeg itself would pick for that container**, so you do not have to specify one. For example:
82+
83+
- `.mp3` → MP3 (`libmp3lame`)
84+
- `.m4a` / `.mp4` → AAC
85+
- `.flac` → FLAC
86+
- `.opus` → Opus (`libopus`)
87+
88+
The exact codec is read from your ffmpeg build at runtime rather than hardcoded, so it always matches what plain ffmpeg would do. This means it can differ between builds: for example, on some builds `.ogg` defaults to FLAC rather than Vorbis. To see what your ffmpeg would choose for a given container, run e.g. `ffmpeg -h muxer=flac` and look at the "Default audio codec" line.
89+
90+
You can always override this by setting `-c:a` explicitly, for example `-c:a libvorbis` to force an Ogg Vorbis file. Note that re-encoding to a lossy codec (MP3, AAC, Opus, …) introduces [generation loss](https://en.wikipedia.org/wiki/Generation_loss); to keep your audio lossless, use a PCM-capable container (WAV, MKV) or a lossless codec such as FLAC.
91+
7692
## Create an MP3 file as output
7793

78-
Normalize an MP3 file and write an MP3 file (you have to explicitly specify the encoder):
94+
Normalize an MP3 file and write an MP3 file. The codec is chosen automatically (MP3 via `libmp3lame`), but you can specify the encoder and bitrate explicitly to control quality:
7995

8096
```bash
8197
ffmpeg-normalize input.mp3 -c:a libmp3lame -b:a 320k -o output.mp3

src/ffmpeg_normalize/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,10 @@ def create_parser() -> argparse.ArgumentParser:
429429
Audio codec to use for output files.
430430
See `ffmpeg -encoders` for a list.
431431
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.
433436
"""
434437
),
435438
)

src/ffmpeg_normalize/_cmd_utils.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ def _get_ffmpeg_env() -> dict[str, str] | None:
4444
r"Duration: (?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})\.(?P<ms>\d{2})"
4545
)
4646

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+
4768

4869
class CommandRunner:
4970
"""
@@ -242,6 +263,114 @@ def get_encoder_sample_formats(encoder: str) -> list[str]:
242263
return formats
243264

244265

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+
245374
def ffmpeg_has_loudnorm() -> bool:
246375
"""
247376
Run feature detection on ffmpeg to see if it supports the loudnorm filter.

src/ffmpeg_normalize/_ffmpeg_normalize.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99

1010
from tqdm import tqdm
1111

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+
)
1320
from ._errors import FFmpegNormalizeError
1421
from ._media_file import MediaFile
1522

@@ -19,8 +26,6 @@
1926
_logger = logging.getLogger(__name__)
2027

2128
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"}
2429

2530

2631
def check_range(number: object, min_r: float, max_r: float, name: str = "") -> float:
@@ -274,13 +279,21 @@ def __init__(
274279
self.keep_mtime = keep_mtime
275280
self.keep_bit_depth = keep_bit_depth
276281

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+
)
284297

285298
# replaygain only works for EBU for now
286299
if self.replaygain and self.normalization_type != "ebu":
@@ -316,14 +329,22 @@ def add_media_file(self, input_file: str, output_file: str) -> None:
316329
if not os.path.exists(input_file):
317330
raise FFmpegNormalizeError(f"file {input_file} does not exist")
318331

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+
)
327348

328349
self.media_files.append(MediaFile(self, input_file, output_file))
329350
self.file_count += 1

0 commit comments

Comments
 (0)