66import re
77from 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+ )
1014from ._errors import FFmpegNormalizeError
1115
1216if TYPE_CHECKING :
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
2142class 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