Skip to content

Commit 200832e

Browse files
committed
feat: preserve input bit depth by default
When encoding to a non-PCM codec (e.g. FLAC), ffmpeg would pick its own default sample format and could promote 16-bit audio to 24-bit. The matching output sample format is now set automatically, so the input bit depth is preserved without needing -e "-sample_fmt ...". The format is chosen per encoder from the formats it actually supports (packed for FLAC, planar for ALAC, etc.); 24-bit is carried in s32 since ffmpeg has no 24-bit sample format. Floating-point sources are left to the encoder to avoid silently converting them to integer. This is enabled by default; use --no-keep-bit-depth to opt out. This changes the bit depth of non-PCM output compared to previous versions.
1 parent 49e4662 commit 200832e

6 files changed

Lines changed: 280 additions & 3 deletions

File tree

src/ffmpeg_normalize/__main__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,26 @@ def create_parser() -> argparse.ArgumentParser:
463463
action="store_true",
464464
help="Copy original, non-normalized audio streams to output file",
465465
)
466+
group_acodec.add_argument(
467+
"--keep-bit-depth",
468+
action=argparse.BooleanOptionalAction,
469+
default=FFmpegNormalize.DEFAULTS["keep_bit_depth"],
470+
help=textwrap.dedent(
471+
"""\
472+
Carry the detected input bit depth through to the output encoder
473+
(default: enabled).
474+
475+
By default, the matching output sample format is set for the chosen
476+
encoder (e.g. FLAC), so you do not need to pass it via
477+
`-e`/`--extra-output-options` yourself. Use `--no-keep-bit-depth` to let
478+
the encoder pick its own sample format instead.
479+
480+
The chosen sample format is constrained to what the encoder supports.
481+
ffmpeg has no 24-bit sample format, so 24-bit audio is carried in the
482+
32-bit `s32` format. Floating-point sources are left to the encoder.
483+
"""
484+
),
485+
)
466486
group_acodec.add_argument(
467487
"-prf",
468488
"--pre-filter",
@@ -721,6 +741,7 @@ def _split_options(opts: str) -> list[str]:
721741
audio_default_only=cli_args.audio_default_only,
722742
keep_other_audio=cli_args.keep_other_audio,
723743
keep_mtime=cli_args.keep_mtime,
744+
keep_bit_depth=cli_args.keep_bit_depth,
724745
)
725746

726747
if cli_args.output and len(cli_args.input) > len(cli_args.output):

src/ffmpeg_normalize/_cmd_utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,44 @@ def get_ffmpeg_exe() -> str:
204204
return ff_path
205205

206206

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

src/ffmpeg_normalize/_ffmpeg_normalize.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class FFmpegNormalize:
8989
audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
9090
keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
9191
keep_mtime (bool, optional): Copy the input file's modification time to the output file. Defaults to False.
92+
keep_bit_depth (bool, optional): Carry the detected input bit depth through to the output encoder. Defaults to True.
9293
9394
Raises:
9495
FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
@@ -135,6 +136,7 @@ class FFmpegNormalize:
135136
"audio_default_only": False,
136137
"keep_other_audio": False,
137138
"keep_mtime": False,
139+
"keep_bit_depth": True,
138140
}
139141

140142
def __init__(
@@ -177,6 +179,7 @@ def __init__(
177179
audio_default_only: bool = False,
178180
keep_other_audio: bool = False,
179181
keep_mtime: bool = False,
182+
keep_bit_depth: bool = True,
180183
):
181184
self.ffmpeg_exe = get_ffmpeg_exe()
182185
self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
@@ -267,6 +270,7 @@ def __init__(
267270
self.keep_other_audio = keep_other_audio
268271

269272
self.keep_mtime = keep_mtime
273+
self.keep_bit_depth = keep_bit_depth
270274

271275
if (
272276
self.audio_codec is None or "pcm" in self.audio_codec

src/ffmpeg_normalize/_media_file.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ def parse_streams(self) -> None:
181181
sample_rate = (
182182
int(sample_rate_match.group(1)) if sample_rate_match else None
183183
)
184-
bit_depth_match = re.search(r"[sfu](\d+)(p|le|be)?", line)
185-
bit_depth = int(bit_depth_match.group(1)) if bit_depth_match else None
184+
bit_depth_match = re.search(r"([sfu])(\d+)(p|le|be)?", line)
185+
bit_depth = int(bit_depth_match.group(2)) if bit_depth_match else None
186+
is_float = bit_depth_match.group(1) == "f" if bit_depth_match else False
186187
self.streams["audio"][stream_id] = AudioStream(
187188
self.ffmpeg_normalize,
188189
self,
@@ -191,6 +192,7 @@ def parse_streams(self) -> None:
191192
bit_depth,
192193
duration,
193194
is_default,
195+
is_float,
194196
)
195197

196198
elif "Video" in line:
@@ -844,6 +846,15 @@ def _second_pass(self) -> Iterator[float]:
844846
for idx in range(len(streams_to_normalize)):
845847
cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
846848

849+
# carry the input bit depth through to the output encoder, if requested
850+
if self.ffmpeg_normalize.keep_bit_depth:
851+
for idx, audio_stream in enumerate(streams_to_normalize):
852+
sample_fmt = audio_stream.get_output_sample_fmt(
853+
self.ffmpeg_normalize.audio_codec
854+
)
855+
if sample_fmt is not None:
856+
cmd.extend([f"-sample_fmt:a:{idx}", sample_fmt])
857+
847858
# ... and subtitles
848859
if not self.ffmpeg_normalize.subtitle_disable:
849860
for s in self.streams["subtitle"].keys():

src/ffmpeg_normalize/_streams.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import re
77
from typing import TYPE_CHECKING, Iterator, Literal, TypedDict, cast
88

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+
)
1014
from ._errors import FFmpegNormalizeError
1115

1216
if TYPE_CHECKING:
@@ -17,6 +21,23 @@
1721

1822
_loudnorm_pattern = re.compile(r"\[Parsed_loudnorm_(\d+)")
1923

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

2142
class EbuLoudnessStatistics(TypedDict):
2243
input_i: float
@@ -100,6 +121,7 @@ def __init__(
100121
bit_depth: int | None,
101122
duration: float | None,
102123
is_default: bool = False,
124+
is_float: bool = False,
103125
):
104126
"""
105127
Create an AudioStream object.
@@ -112,6 +134,7 @@ def __init__(
112134
bit_depth (int): bit depth in bits
113135
duration (float): duration in seconds
114136
is_default (bool): Whether this stream has the default disposition flag
137+
is_float (bool): Whether the stream uses a floating-point sample format
115138
"""
116139
super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
117140

@@ -127,6 +150,7 @@ def __init__(
127150

128151
self.duration = duration
129152
self.is_default = is_default
153+
self.is_float = is_float
130154

131155
@staticmethod
132156
def _constrain(
@@ -205,6 +229,84 @@ def get_pcm_codec(self) -> str:
205229
)
206230
return "pcm_s16le"
207231

232+
def get_output_sample_fmt(self, codec: str | None) -> str | None:
233+
"""
234+
Choose an output sample format for the given encoder that preserves the
235+
detected input bit depth as closely as possible.
236+
237+
Used by the ``keep_bit_depth`` option (enabled by default) so that
238+
encoders such as FLAC, which would otherwise pick their own default
239+
sample format, retain the input bit depth. Only integer formats are
240+
considered, and floating-point sources are left to the encoder, since
241+
keeping bit depth is only meaningful for integer PCM sources.
242+
243+
Note that ffmpeg has no 24-bit sample format; 24-bit audio is carried in
244+
the 32-bit ``s32`` format, and the encoder stores it accordingly.
245+
246+
Args:
247+
codec: The output audio codec name, or None for the PCM default.
248+
249+
Returns:
250+
str | None: The chosen sample format, or None if none should be set
251+
(unknown bit depth, floating-point source, no explicit codec, no
252+
encoder info, or an encoder without integer sample formats). In
253+
all of these cases the encoder default is used.
254+
"""
255+
if not self.bit_depth:
256+
_logger.debug(
257+
f"{self.media_file.input_file}: Could not determine input bit depth "
258+
f"for stream {self.stream_id}; leaving the sample format to the encoder."
259+
)
260+
return None
261+
262+
if self.is_float:
263+
# Pinning an integer sample format would silently convert a
264+
# floating-point source to integer, so leave it to the encoder.
265+
_logger.debug(
266+
f"{self.media_file.input_file}: Stream {self.stream_id} is "
267+
"floating-point; leaving the sample format to the encoder."
268+
)
269+
return None
270+
271+
if codec is None:
272+
# The PCM default path already derives the bit depth from the input
273+
# via get_pcm_codec(), so there is nothing to set here.
274+
_logger.debug(
275+
"keep_bit_depth has no effect for the default PCM output; the "
276+
"input bit depth is already preserved."
277+
)
278+
return None
279+
280+
supported = get_encoder_sample_formats(codec)
281+
if not supported:
282+
_logger.debug(
283+
f"Could not determine supported sample formats for codec '{codec}'; "
284+
"not setting an explicit sample format."
285+
)
286+
return None
287+
288+
# Only consider integer formats, since keeping bit depth is meaningful
289+
# for integer PCM sources (e.g. FLAC, ALAC).
290+
int_formats = [
291+
fmt
292+
for fmt in supported
293+
if fmt in _SAMPLE_FMT_INFO and not _SAMPLE_FMT_INFO[fmt][1]
294+
]
295+
if not int_formats:
296+
_logger.debug(
297+
f"Encoder '{codec}' supports no integer sample formats; "
298+
"leaving the sample format to the encoder."
299+
)
300+
return None
301+
302+
# Prefer the smallest integer format that holds at least the input bit
303+
# depth; fall back to the largest available if none is big enough.
304+
candidates = sorted(int_formats, key=lambda fmt: _SAMPLE_FMT_INFO[fmt][0])
305+
for fmt in candidates:
306+
if _SAMPLE_FMT_INFO[fmt][0] >= self.bit_depth:
307+
return fmt
308+
return candidates[-1]
309+
208310
def _get_filter_str_with_pre_filter(self, current_filter: str) -> str:
209311
"""
210312
Get a filter string for current_filter, with the pre-filter

0 commit comments

Comments
 (0)