ffmpeg_normalize

 1from ._errors import FFmpegNormalizeError
 2from ._ffmpeg_normalize import FFmpegNormalize
 3from ._media_file import MediaFile
 4from ._streams import AudioStream, MediaStream, SubtitleStream, VideoStream
 5from ._version import __version__
 6
 7__module_name__ = "ffmpeg_normalize"
 8
 9__all__ = [
10    "FFmpegNormalize",
11    "FFmpegNormalizeError",
12    "MediaFile",
13    "AudioStream",
14    "VideoStream",
15    "SubtitleStream",
16    "MediaStream",
17    "__version__",
18]
class FFmpegNormalize:
 51class FFmpegNormalize:
 52    """
 53    ffmpeg-normalize class.
 54
 55    Args:
 56        normalization_type (str, optional): Normalization type. Defaults to "ebu".
 57        target_level (float, optional): Target level. Defaults to -23.0.
 58        print_stats (bool, optional): Print loudnorm stats. Defaults to False.
 59        loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
 60        keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
 61        keep_lra_above_loudness_range_target (bool, optional): Keep input loudness range above loudness range target. Defaults to False.
 62        true_peak (float, optional): True peak. Defaults to -2.0.
 63        offset (float, optional): Offset. Defaults to 0.0.
 64        lower_only (bool, optional): Whether the audio should not increase in loudness. Defaults to False.
 65        auto_lower_loudness_target (bool, optional): Automatically lower EBU Integrated Loudness Target.
 66        dual_mono (bool, optional): Dual mono. Defaults to False.
 67        dynamic (bool, optional): Dynamic. Defaults to False.
 68        audio_codec (str, optional): Audio codec. Defaults to "pcm_s16le".
 69        audio_bitrate (float, optional): Audio bitrate. Defaults to None.
 70        sample_rate (int, optional): Sample rate. Defaults to None.
 71        audio_channels (int | None, optional): Audio channels. Defaults to None.
 72        keep_original_audio (bool, optional): Keep original audio. Defaults to False.
 73        pre_filter (str, optional): Pre filter. Defaults to None.
 74        post_filter (str, optional): Post filter. Defaults to None.
 75        video_codec (str, optional): Video codec. Defaults to "copy".
 76        video_disable (bool, optional): Disable video. Defaults to False.
 77        subtitle_disable (bool, optional): Disable subtitles. Defaults to False.
 78        metadata_disable (bool, optional): Disable metadata. Defaults to False.
 79        chapters_disable (bool, optional): Disable chapters. Defaults to False.
 80        extra_input_options (list, optional): Extra input options. Defaults to None.
 81        extra_output_options (list, optional): Extra output options. Defaults to None.
 82        output_format (str, optional): Output format. Defaults to None.
 83        extension (str, optional): Output file extension to use for output files that were not explicitly specified. Defaults to "mkv".
 84        dry_run (bool, optional): Dry run. Defaults to False.
 85        debug (bool, optional): Debug. Defaults to False.
 86        progress (bool, optional): Progress. Defaults to False.
 87        replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
 88
 89    Raises:
 90        FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
 91    """
 92
 93    def __init__(
 94        self,
 95        normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
 96        target_level: float = -23.0,
 97        print_stats: bool = False,
 98        # threshold=0.5,
 99        loudness_range_target: float = 7.0,
100        keep_loudness_range_target: bool = False,
101        keep_lra_above_loudness_range_target: bool = False,
102        true_peak: float = -2.0,
103        offset: float = 0.0,
104        lower_only: bool = False,
105        auto_lower_loudness_target: bool = False,
106        dual_mono: bool = False,
107        dynamic: bool = False,
108        audio_codec: str = "pcm_s16le",
109        audio_bitrate: float | None = None,
110        sample_rate: float | int | None = None,
111        audio_channels: int | None = None,
112        keep_original_audio: bool = False,
113        pre_filter: str | None = None,
114        post_filter: str | None = None,
115        video_codec: str = "copy",
116        video_disable: bool = False,
117        subtitle_disable: bool = False,
118        metadata_disable: bool = False,
119        chapters_disable: bool = False,
120        extra_input_options: list[str] | None = None,
121        extra_output_options: list[str] | None = None,
122        output_format: str | None = None,
123        extension: str = "mkv",
124        dry_run: bool = False,
125        debug: bool = False,
126        progress: bool = False,
127        replaygain: bool = False,
128    ):
129        self.ffmpeg_exe = get_ffmpeg_exe()
130        self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
131
132        if normalization_type not in NORMALIZATION_TYPES:
133            raise FFmpegNormalizeError(
134                "Normalization type must be: 'ebu', 'rms', or 'peak'"
135            )
136        self.normalization_type = normalization_type
137
138        if not self.has_loudnorm_capabilities and self.normalization_type == "ebu":
139            raise FFmpegNormalizeError(
140                "Your ffmpeg does not support the 'loudnorm' EBU R128 filter. "
141                "Please install ffmpeg v4.2 or above, or choose another normalization type."
142            )
143
144        if self.normalization_type == "ebu":
145            self.target_level = check_range(target_level, -70, -5, name="target_level")
146        else:
147            self.target_level = check_range(target_level, -99, 0, name="target_level")
148
149        self.print_stats = print_stats
150
151        # self.threshold = float(threshold)
152
153        self.loudness_range_target = check_range(
154            loudness_range_target, 1, 50, name="loudness_range_target"
155        )
156
157        self.keep_loudness_range_target = keep_loudness_range_target
158
159        if self.keep_loudness_range_target and loudness_range_target != 7.0:
160            _logger.warning(
161                "Setting --keep-loudness-range-target will override your set loudness range target value! "
162                "Remove --keep-loudness-range-target or remove the --lrt/--loudness-range-target option."
163            )
164
165        self.keep_lra_above_loudness_range_target = keep_lra_above_loudness_range_target
166
167        if (
168            self.keep_loudness_range_target
169            and self.keep_lra_above_loudness_range_target
170        ):
171            raise FFmpegNormalizeError(
172                "Options --keep-loudness-range-target and --keep-lra-above-loudness-range-target are mutually exclusive! "
173                "Please choose just one of the two options."
174            )
175
176        self.true_peak = check_range(true_peak, -9, 0, name="true_peak")
177        self.offset = check_range(offset, -99, 99, name="offset")
178        self.lower_only = lower_only
179        self.auto_lower_loudness_target = auto_lower_loudness_target
180
181        # Ensure library user is passing correct types
182        assert isinstance(dual_mono, bool), "dual_mono must be bool"
183        assert isinstance(dynamic, bool), "dynamic must be bool"
184
185        self.dual_mono = dual_mono
186        self.dynamic = dynamic
187        self.sample_rate = None if sample_rate is None else int(sample_rate)
188        self.audio_channels = None if audio_channels is None else int(audio_channels)
189
190        self.audio_codec = audio_codec
191        self.audio_bitrate = audio_bitrate
192        self.keep_original_audio = keep_original_audio
193        self.video_codec = video_codec
194        self.video_disable = video_disable
195        self.subtitle_disable = subtitle_disable
196        self.metadata_disable = metadata_disable
197        self.chapters_disable = chapters_disable
198
199        self.extra_input_options = extra_input_options
200        self.extra_output_options = extra_output_options
201        self.pre_filter = pre_filter
202        self.post_filter = post_filter
203
204        self.output_format = output_format
205        self.extension = extension
206        self.dry_run = dry_run
207        self.debug = debug
208        self.progress = progress
209        self.replaygain = replaygain
210
211        if (
212            self.audio_codec is None or "pcm" in self.audio_codec
213        ) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
214            raise FFmpegNormalizeError(
215                f"Output format {self.output_format} does not support PCM audio. "
216                "Please choose a suitable audio codec with the -c:a option."
217            )
218
219        # replaygain only works for EBU for now
220        if self.replaygain and self.normalization_type != "ebu":
221            raise FFmpegNormalizeError(
222                "ReplayGain only works for EBU normalization type for now."
223            )
224
225        self.stats: list[LoudnessStatisticsWithMetadata] = []
226        self.media_files: list[MediaFile] = []
227        self.file_count = 0
228
229    def add_media_file(self, input_file: str, output_file: str) -> None:
230        """
231        Add a media file to normalize
232
233        Args:
234            input_file (str): Path to input file
235            output_file (str): Path to output file
236        """
237        if not os.path.exists(input_file):
238            raise FFmpegNormalizeError(f"file {input_file} does not exist")
239
240        ext = os.path.splitext(output_file)[1][1:]
241        if (
242            self.audio_codec is None or "pcm" in self.audio_codec
243        ) and ext in PCM_INCOMPATIBLE_EXTS:
244            raise FFmpegNormalizeError(
245                f"Output extension {ext} does not support PCM audio. "
246                "Please choose a suitable audio codec with the -c:a option."
247            )
248
249        self.media_files.append(MediaFile(self, input_file, output_file))
250        self.file_count += 1
251
252    def run_normalization(self) -> None:
253        """
254        Run the normalization procedures
255        """
256        for index, media_file in enumerate(
257            tqdm(self.media_files, desc="File", disable=not self.progress, position=0)
258        ):
259            _logger.info(
260                f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
261            )
262
263            try:
264                media_file.run_normalization()
265            except Exception as e:
266                if len(self.media_files) > 1:
267                    # simply warn and do not die
268                    _logger.error(
269                        f"Error processing input file {media_file}, will "
270                        f"continue batch-processing. Error was: {e}"
271                    )
272                else:
273                    # raise the error so the program will exit
274                    raise e
275
276        if self.print_stats:
277            json.dump(
278                list(
279                    chain.from_iterable(
280                        media_file.get_stats() for media_file in self.media_files
281                    )
282                ),
283                sys.stdout,
284                indent=4,
285            )
286            print()

ffmpeg-normalize class.

Arguments:
  • normalization_type (str, optional): Normalization type. Defaults to "ebu".
  • target_level (float, optional): Target level. Defaults to -23.0.
  • print_stats (bool, optional): Print loudnorm stats. Defaults to False.
  • loudness_range_target (float, optional): Loudness range target. Defaults to 7.0.
  • keep_loudness_range_target (bool, optional): Keep loudness range target. Defaults to False.
  • keep_lra_above_loudness_range_target (bool, optional): Keep input loudness range above loudness range target. Defaults to False.
  • true_peak (float, optional): True peak. Defaults to -2.0.
  • offset (float, optional): Offset. Defaults to 0.0.
  • lower_only (bool, optional): Whether the audio should not increase in loudness. Defaults to False.
  • auto_lower_loudness_target (bool, optional): Automatically lower EBU Integrated Loudness Target.
  • dual_mono (bool, optional): Dual mono. Defaults to False.
  • dynamic (bool, optional): Dynamic. Defaults to False.
  • audio_codec (str, optional): Audio codec. Defaults to "pcm_s16le".
  • audio_bitrate (float, optional): Audio bitrate. Defaults to None.
  • sample_rate (int, optional): Sample rate. Defaults to None.
  • audio_channels (int | None, optional): Audio channels. Defaults to None.
  • keep_original_audio (bool, optional): Keep original audio. Defaults to False.
  • pre_filter (str, optional): Pre filter. Defaults to None.
  • post_filter (str, optional): Post filter. Defaults to None.
  • video_codec (str, optional): Video codec. Defaults to "copy".
  • video_disable (bool, optional): Disable video. Defaults to False.
  • subtitle_disable (bool, optional): Disable subtitles. Defaults to False.
  • metadata_disable (bool, optional): Disable metadata. Defaults to False.
  • chapters_disable (bool, optional): Disable chapters. Defaults to False.
  • extra_input_options (list, optional): Extra input options. Defaults to None.
  • extra_output_options (list, optional): Extra output options. Defaults to None.
  • output_format (str, optional): Output format. Defaults to None.
  • extension (str, optional): Output file extension to use for output files that were not explicitly specified. Defaults to "mkv".
  • dry_run (bool, optional): Dry run. Defaults to False.
  • debug (bool, optional): Debug. Defaults to False.
  • progress (bool, optional): Progress. Defaults to False.
  • replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
Raises:
  • FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
FFmpegNormalize( normalization_type: Literal['ebu', 'rms', 'peak'] = 'ebu', target_level: float = -23.0, print_stats: bool = False, loudness_range_target: float = 7.0, keep_loudness_range_target: bool = False, keep_lra_above_loudness_range_target: bool = False, true_peak: float = -2.0, offset: float = 0.0, lower_only: bool = False, auto_lower_loudness_target: bool = False, dual_mono: bool = False, dynamic: bool = False, audio_codec: str = 'pcm_s16le', audio_bitrate: float | None = None, sample_rate: float | int | None = None, audio_channels: int | None = None, keep_original_audio: bool = False, pre_filter: str | None = None, post_filter: str | None = None, video_codec: str = 'copy', video_disable: bool = False, subtitle_disable: bool = False, metadata_disable: bool = False, chapters_disable: bool = False, extra_input_options: list[str] | None = None, extra_output_options: list[str] | None = None, output_format: str | None = None, extension: str = 'mkv', dry_run: bool = False, debug: bool = False, progress: bool = False, replaygain: bool = False)
 93    def __init__(
 94        self,
 95        normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
 96        target_level: float = -23.0,
 97        print_stats: bool = False,
 98        # threshold=0.5,
 99        loudness_range_target: float = 7.0,
100        keep_loudness_range_target: bool = False,
101        keep_lra_above_loudness_range_target: bool = False,
102        true_peak: float = -2.0,
103        offset: float = 0.0,
104        lower_only: bool = False,
105        auto_lower_loudness_target: bool = False,
106        dual_mono: bool = False,
107        dynamic: bool = False,
108        audio_codec: str = "pcm_s16le",
109        audio_bitrate: float | None = None,
110        sample_rate: float | int | None = None,
111        audio_channels: int | None = None,
112        keep_original_audio: bool = False,
113        pre_filter: str | None = None,
114        post_filter: str | None = None,
115        video_codec: str = "copy",
116        video_disable: bool = False,
117        subtitle_disable: bool = False,
118        metadata_disable: bool = False,
119        chapters_disable: bool = False,
120        extra_input_options: list[str] | None = None,
121        extra_output_options: list[str] | None = None,
122        output_format: str | None = None,
123        extension: str = "mkv",
124        dry_run: bool = False,
125        debug: bool = False,
126        progress: bool = False,
127        replaygain: bool = False,
128    ):
129        self.ffmpeg_exe = get_ffmpeg_exe()
130        self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
131
132        if normalization_type not in NORMALIZATION_TYPES:
133            raise FFmpegNormalizeError(
134                "Normalization type must be: 'ebu', 'rms', or 'peak'"
135            )
136        self.normalization_type = normalization_type
137
138        if not self.has_loudnorm_capabilities and self.normalization_type == "ebu":
139            raise FFmpegNormalizeError(
140                "Your ffmpeg does not support the 'loudnorm' EBU R128 filter. "
141                "Please install ffmpeg v4.2 or above, or choose another normalization type."
142            )
143
144        if self.normalization_type == "ebu":
145            self.target_level = check_range(target_level, -70, -5, name="target_level")
146        else:
147            self.target_level = check_range(target_level, -99, 0, name="target_level")
148
149        self.print_stats = print_stats
150
151        # self.threshold = float(threshold)
152
153        self.loudness_range_target = check_range(
154            loudness_range_target, 1, 50, name="loudness_range_target"
155        )
156
157        self.keep_loudness_range_target = keep_loudness_range_target
158
159        if self.keep_loudness_range_target and loudness_range_target != 7.0:
160            _logger.warning(
161                "Setting --keep-loudness-range-target will override your set loudness range target value! "
162                "Remove --keep-loudness-range-target or remove the --lrt/--loudness-range-target option."
163            )
164
165        self.keep_lra_above_loudness_range_target = keep_lra_above_loudness_range_target
166
167        if (
168            self.keep_loudness_range_target
169            and self.keep_lra_above_loudness_range_target
170        ):
171            raise FFmpegNormalizeError(
172                "Options --keep-loudness-range-target and --keep-lra-above-loudness-range-target are mutually exclusive! "
173                "Please choose just one of the two options."
174            )
175
176        self.true_peak = check_range(true_peak, -9, 0, name="true_peak")
177        self.offset = check_range(offset, -99, 99, name="offset")
178        self.lower_only = lower_only
179        self.auto_lower_loudness_target = auto_lower_loudness_target
180
181        # Ensure library user is passing correct types
182        assert isinstance(dual_mono, bool), "dual_mono must be bool"
183        assert isinstance(dynamic, bool), "dynamic must be bool"
184
185        self.dual_mono = dual_mono
186        self.dynamic = dynamic
187        self.sample_rate = None if sample_rate is None else int(sample_rate)
188        self.audio_channels = None if audio_channels is None else int(audio_channels)
189
190        self.audio_codec = audio_codec
191        self.audio_bitrate = audio_bitrate
192        self.keep_original_audio = keep_original_audio
193        self.video_codec = video_codec
194        self.video_disable = video_disable
195        self.subtitle_disable = subtitle_disable
196        self.metadata_disable = metadata_disable
197        self.chapters_disable = chapters_disable
198
199        self.extra_input_options = extra_input_options
200        self.extra_output_options = extra_output_options
201        self.pre_filter = pre_filter
202        self.post_filter = post_filter
203
204        self.output_format = output_format
205        self.extension = extension
206        self.dry_run = dry_run
207        self.debug = debug
208        self.progress = progress
209        self.replaygain = replaygain
210
211        if (
212            self.audio_codec is None or "pcm" in self.audio_codec
213        ) and self.output_format in PCM_INCOMPATIBLE_FORMATS:
214            raise FFmpegNormalizeError(
215                f"Output format {self.output_format} does not support PCM audio. "
216                "Please choose a suitable audio codec with the -c:a option."
217            )
218
219        # replaygain only works for EBU for now
220        if self.replaygain and self.normalization_type != "ebu":
221            raise FFmpegNormalizeError(
222                "ReplayGain only works for EBU normalization type for now."
223            )
224
225        self.stats: list[LoudnessStatisticsWithMetadata] = []
226        self.media_files: list[MediaFile] = []
227        self.file_count = 0
ffmpeg_exe
has_loudnorm_capabilities
normalization_type
print_stats
loudness_range_target
keep_loudness_range_target
keep_lra_above_loudness_range_target
true_peak
offset
lower_only
auto_lower_loudness_target
dual_mono
dynamic
sample_rate
audio_channels
audio_codec
audio_bitrate
keep_original_audio
video_codec
video_disable
subtitle_disable
metadata_disable
chapters_disable
extra_input_options
extra_output_options
pre_filter
post_filter
output_format
extension
dry_run
debug
progress
replaygain
stats: list[ffmpeg_normalize._streams.LoudnessStatisticsWithMetadata]
media_files: list[MediaFile]
file_count
def add_media_file(self, input_file: str, output_file: str) -> None:
229    def add_media_file(self, input_file: str, output_file: str) -> None:
230        """
231        Add a media file to normalize
232
233        Args:
234            input_file (str): Path to input file
235            output_file (str): Path to output file
236        """
237        if not os.path.exists(input_file):
238            raise FFmpegNormalizeError(f"file {input_file} does not exist")
239
240        ext = os.path.splitext(output_file)[1][1:]
241        if (
242            self.audio_codec is None or "pcm" in self.audio_codec
243        ) and ext in PCM_INCOMPATIBLE_EXTS:
244            raise FFmpegNormalizeError(
245                f"Output extension {ext} does not support PCM audio. "
246                "Please choose a suitable audio codec with the -c:a option."
247            )
248
249        self.media_files.append(MediaFile(self, input_file, output_file))
250        self.file_count += 1

Add a media file to normalize

Arguments:
  • input_file (str): Path to input file
  • output_file (str): Path to output file
def run_normalization(self) -> None:
252    def run_normalization(self) -> None:
253        """
254        Run the normalization procedures
255        """
256        for index, media_file in enumerate(
257            tqdm(self.media_files, desc="File", disable=not self.progress, position=0)
258        ):
259            _logger.info(
260                f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
261            )
262
263            try:
264                media_file.run_normalization()
265            except Exception as e:
266                if len(self.media_files) > 1:
267                    # simply warn and do not die
268                    _logger.error(
269                        f"Error processing input file {media_file}, will "
270                        f"continue batch-processing. Error was: {e}"
271                    )
272                else:
273                    # raise the error so the program will exit
274                    raise e
275
276        if self.print_stats:
277            json.dump(
278                list(
279                    chain.from_iterable(
280                        media_file.get_stats() for media_file in self.media_files
281                    )
282                ),
283                sys.stdout,
284                indent=4,
285            )
286            print()

Run the normalization procedures

class FFmpegNormalizeError(builtins.Exception):
2class FFmpegNormalizeError(Exception):
3    pass

Common base class for all non-exit exceptions.

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
add_note
args
class MediaFile:
 56class MediaFile:
 57    """
 58    Class that holds a file, its streams and adjustments
 59    """
 60
 61    def __init__(
 62        self, ffmpeg_normalize: FFmpegNormalize, input_file: str, output_file: str
 63    ):
 64        """
 65        Initialize a media file for later normalization by parsing the streams.
 66
 67        Args:
 68            ffmpeg_normalize (FFmpegNormalize): reference to overall settings
 69            input_file (str): Path to input file
 70            output_file (str): Path to output file
 71        """
 72        self.ffmpeg_normalize = ffmpeg_normalize
 73        self.skip = False
 74        self.input_file = input_file
 75        self.output_file = output_file
 76        current_ext = os.path.splitext(output_file)[1][1:]
 77        # we need to check if it's empty, e.g. /dev/null or NUL
 78        if current_ext == "" or self.output_file == os.devnull:
 79            self.output_ext = self.ffmpeg_normalize.extension
 80        else:
 81            self.output_ext = current_ext
 82        self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
 83
 84        self.parse_streams()
 85
 86    def _stream_ids(self) -> list[int]:
 87        """
 88        Get all stream IDs of this file.
 89
 90        Returns:
 91            list: List of stream IDs
 92        """
 93        return (
 94            list(self.streams["audio"].keys())
 95            + list(self.streams["video"].keys())
 96            + list(self.streams["subtitle"].keys())
 97        )
 98
 99    def __repr__(self) -> str:
100        return os.path.basename(self.input_file)
101
102    def parse_streams(self) -> None:
103        """
104        Try to parse all input streams from file and set them in self.streams.
105
106        Raises:
107            FFmpegNormalizeError: If no audio streams are found
108        """
109        _logger.debug(f"Parsing streams of {self.input_file}")
110
111        cmd = [
112            self.ffmpeg_normalize.ffmpeg_exe,
113            "-i",
114            self.input_file,
115            "-c",
116            "copy",
117            "-t",
118            "0",
119            "-map",
120            "0",
121            "-f",
122            "null",
123            os.devnull,
124        ]
125
126        output = CommandRunner().run_command(cmd).get_output()
127
128        _logger.debug("Stream parsing command output:")
129        _logger.debug(output)
130
131        output_lines = [line.strip() for line in output.split("\n")]
132
133        duration = None
134        for line in output_lines:
135            if "Duration" in line:
136                if duration_search := DUR_REGEX.search(line):
137                    duration = _to_ms(**duration_search.groupdict()) / 1000
138                    _logger.debug(f"Found duration: {duration} s")
139                else:
140                    _logger.warning("Could not extract duration from input file!")
141
142            if not line.startswith("Stream"):
143                continue
144
145            if stream_id_match := re.search(r"#0:([\d]+)", line):
146                stream_id = int(stream_id_match.group(1))
147                if stream_id in self._stream_ids():
148                    continue
149            else:
150                continue
151
152            if "Audio" in line:
153                _logger.debug(f"Found audio stream at index {stream_id}")
154                sample_rate_match = re.search(r"(\d+) Hz", line)
155                sample_rate = (
156                    int(sample_rate_match.group(1)) if sample_rate_match else None
157                )
158                bit_depth_match = re.search(r"[sfu](\d+)(p|le|be)?", line)
159                bit_depth = int(bit_depth_match.group(1)) if bit_depth_match else None
160                self.streams["audio"][stream_id] = AudioStream(
161                    self.ffmpeg_normalize,
162                    self,
163                    stream_id,
164                    sample_rate,
165                    bit_depth,
166                    duration,
167                )
168
169            elif "Video" in line:
170                _logger.debug(f"Found video stream at index {stream_id}")
171                self.streams["video"][stream_id] = VideoStream(
172                    self.ffmpeg_normalize, self, stream_id
173                )
174
175            elif "Subtitle" in line:
176                _logger.debug(f"Found subtitle stream at index {stream_id}")
177                self.streams["subtitle"][stream_id] = SubtitleStream(
178                    self.ffmpeg_normalize, self, stream_id
179                )
180
181        if not self.streams["audio"]:
182            raise FFmpegNormalizeError(
183                f"Input file {self.input_file} does not contain any audio streams"
184            )
185
186        if (
187            self.output_ext.lower() in ONE_STREAM
188            and len(self.streams["audio"].values()) > 1
189        ):
190            _logger.warning(
191                "Output file only supports one stream. Keeping only first audio stream."
192            )
193            first_stream = list(self.streams["audio"].values())[0]
194            self.streams["audio"] = {first_stream.stream_id: first_stream}
195            self.streams["video"] = {}
196            self.streams["subtitle"] = {}
197
198    def run_normalization(self) -> None:
199        """
200        Run the normalization process for this file.
201        """
202        _logger.debug(f"Running normalization for {self.input_file}")
203
204        # run the first pass to get loudness stats
205        self._first_pass()
206
207        # shortcut to apply replaygain
208        if self.ffmpeg_normalize.replaygain:
209            self._run_replaygain()
210            return
211
212        # run the second pass as a whole
213        if self.ffmpeg_normalize.progress:
214            with tqdm(
215                total=100,
216                position=1,
217                desc="Second Pass",
218                bar_format=TQDM_BAR_FORMAT,
219            ) as pbar:
220                for progress in self._second_pass():
221                    pbar.update(progress - pbar.n)
222        else:
223            for _ in self._second_pass():
224                pass
225
226        _logger.info(f"Normalized file written to {self.output_file}")
227
228    def _run_replaygain(self) -> None:
229        """
230        Run the replaygain process for this file.
231        """
232        _logger.debug(f"Running replaygain for {self.input_file}")
233
234        # get the audio streams
235        audio_streams = list(self.streams["audio"].values())
236
237        # get the loudnorm stats from the first pass
238        loudnorm_stats = audio_streams[0].loudness_statistics["ebu_pass1"]
239
240        if loudnorm_stats is None:
241            _logger.error("no loudnorm stats available in first pass stats!")
242            return
243
244        # apply the replaygain tag from the first audio stream (to all audio streams)
245        if len(audio_streams) > 1:
246            _logger.warning(
247                f"Your input file has {len(audio_streams)} audio streams. "
248                "Only the first audio stream's replaygain tag will be applied. "
249                "All audio streams will receive the same tag."
250            )
251
252        target_level = self.ffmpeg_normalize.target_level
253        input_i = loudnorm_stats["input_i"]  # Integrated loudness
254        input_tp = loudnorm_stats["input_tp"]  # True peak
255
256        if input_i is None or input_tp is None:
257            _logger.error("no input_i or input_tp available in first pass stats!")
258            return
259
260        track_gain = -(input_i - target_level)  # dB
261        track_peak = 10 ** (input_tp / 20)  # linear scale
262
263        _logger.debug(f"Track gain: {track_gain} dB")
264        _logger.debug(f"Track peak: {track_peak}")
265
266        self._write_replaygain_tags(track_gain, track_peak)
267
268    def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
269        """
270        Write the replaygain tags to the input file.
271
272        This is based on the code from bohning/usdb_syncer, licensed under the MIT license.
273        See: https://github.com/bohning/usdb_syncer/blob/2fa638c4f487dffe9f5364f91e156ba54cb20233/src/usdb_syncer/resource_dl.py
274        """
275        _logger.debug(f"Writing ReplayGain tags to {self.input_file}")
276
277        input_file_ext = os.path.splitext(self.input_file)[1]
278        if input_file_ext == ".mp3":
279            mp3 = MP3(self.input_file, ID3=ID3)
280            if not mp3.tags:
281                return
282            mp3.tags.add(
283                TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=[f"{track_gain:.2f} dB"])
284            )
285            mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_PEAK", text=[f"{track_peak:.6f}"]))
286            mp3.save()
287        elif input_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
288            mp4 = MP4(self.input_file)
289            if not mp4.tags:
290                mp4.add_tags()
291            if not mp4.tags:
292                return
293            mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = [
294                f"{track_gain:.2f} dB".encode()
295            ]
296            mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = [
297                f"{track_peak:.6f}".encode()
298            ]
299            mp4.save()
300        elif input_file_ext == ".ogg":
301            ogg = OggVorbis(self.input_file)
302            ogg["REPLAYGAIN_TRACK_GAIN"] = [f"{track_gain:.2f} dB"]
303            ogg["REPLAYGAIN_TRACK_PEAK"] = [f"{track_peak:.6f}"]
304            ogg.save()
305        elif input_file_ext == ".opus":
306            opus = OggOpus(self.input_file)
307            # See https://datatracker.ietf.org/doc/html/rfc7845#section-5.2.1
308            opus["R128_TRACK_GAIN"] = [str(round(256 * track_gain))]
309            opus.save()
310        else:
311            _logger.error(
312                f"Unsupported input file extension: {input_file_ext} for writing replaygain tags."
313                "Only .mp3, .mp4/.m4a, .ogg, .opus are supported."
314                "If you think this should support more formats, please let me know at "
315                "https://github.com/slhck/ffmpeg-normalize/issues"
316            )
317            return
318
319        _logger.info(
320            f"Successfully wrote replaygain tags to input file {self.input_file}"
321        )
322
323    def _can_write_output_video(self) -> bool:
324        """
325        Determine whether the output file can contain video at all.
326
327        Returns:
328            bool: True if the output file can contain video, False otherwise
329        """
330        if self.output_ext.lower() in AUDIO_ONLY_FORMATS:
331            return False
332
333        return not self.ffmpeg_normalize.video_disable
334
335    def _first_pass(self) -> None:
336        """
337        Run the first pass of the normalization process.
338        """
339        _logger.debug(f"Parsing normalization info for {self.input_file}")
340
341        for index, audio_stream in enumerate(self.streams["audio"].values()):
342            if self.ffmpeg_normalize.normalization_type == "ebu":
343                fun = getattr(audio_stream, "parse_loudnorm_stats")
344            else:
345                fun = getattr(audio_stream, "parse_astats")
346
347            if self.ffmpeg_normalize.progress:
348                with tqdm(
349                    total=100,
350                    position=1,
351                    desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
352                    bar_format=TQDM_BAR_FORMAT,
353                ) as pbar:
354                    for progress in fun():
355                        pbar.update(progress - pbar.n)
356            else:
357                for _ in fun():
358                    pass
359
360    def _get_audio_filter_cmd(self) -> tuple[str, list[str]]:
361        """
362        Return the audio filter command and output labels needed.
363
364        Returns:
365            tuple[str, list[str]]: filter_complex command and the required output labels
366        """
367        filter_chains = []
368        output_labels = []
369
370        for audio_stream in self.streams["audio"].values():
371            skip_normalization = False
372            if self.ffmpeg_normalize.lower_only:
373                if self.ffmpeg_normalize.normalization_type == "ebu":
374                    if (
375                        audio_stream.loudness_statistics["ebu_pass1"] is not None
376                        and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
377                        < self.ffmpeg_normalize.target_level
378                    ):
379                        skip_normalization = True
380                elif self.ffmpeg_normalize.normalization_type == "peak":
381                    if (
382                        audio_stream.loudness_statistics["max"] is not None
383                        and audio_stream.loudness_statistics["max"]
384                        < self.ffmpeg_normalize.target_level
385                    ):
386                        skip_normalization = True
387                elif self.ffmpeg_normalize.normalization_type == "rms":
388                    if (
389                        audio_stream.loudness_statistics["mean"] is not None
390                        and audio_stream.loudness_statistics["mean"]
391                        < self.ffmpeg_normalize.target_level
392                    ):
393                        skip_normalization = True
394
395            if skip_normalization:
396                _logger.warning(
397                    f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
398                )
399                normalization_filter = "acopy"
400            else:
401                if self.ffmpeg_normalize.normalization_type == "ebu":
402                    normalization_filter = audio_stream.get_second_pass_opts_ebu()
403                else:
404                    normalization_filter = audio_stream.get_second_pass_opts_peakrms()
405
406            input_label = f"[0:{audio_stream.stream_id}]"
407            output_label = f"[norm{audio_stream.stream_id}]"
408            output_labels.append(output_label)
409
410            filter_chain = []
411
412            if self.ffmpeg_normalize.pre_filter:
413                filter_chain.append(self.ffmpeg_normalize.pre_filter)
414
415            filter_chain.append(normalization_filter)
416
417            if self.ffmpeg_normalize.post_filter:
418                filter_chain.append(self.ffmpeg_normalize.post_filter)
419
420            filter_chains.append(input_label + ",".join(filter_chain) + output_label)
421
422        filter_complex_cmd = ";".join(filter_chains)
423
424        return filter_complex_cmd, output_labels
425
426    def _second_pass(self) -> Iterator[float]:
427        """
428        Construct the second pass command and run it.
429
430        FIXME: make this method simpler
431        """
432        _logger.info(f"Running second pass for {self.input_file}")
433
434        # get the target output stream types depending on the options
435        output_stream_types: list[Literal["audio", "video", "subtitle"]] = ["audio"]
436        if self._can_write_output_video():
437            output_stream_types.append("video")
438        if not self.ffmpeg_normalize.subtitle_disable:
439            output_stream_types.append("subtitle")
440
441        # base command, here we will add all other options
442        cmd = [self.ffmpeg_normalize.ffmpeg_exe, "-hide_banner", "-y"]
443
444        # extra options (if any)
445        if self.ffmpeg_normalize.extra_input_options:
446            cmd.extend(self.ffmpeg_normalize.extra_input_options)
447
448        # get complex filter command
449        audio_filter_cmd, output_labels = self._get_audio_filter_cmd()
450
451        # add input file and basic filter
452        cmd.extend(["-i", self.input_file, "-filter_complex", audio_filter_cmd])
453
454        # map metadata, only if needed
455        if self.ffmpeg_normalize.metadata_disable:
456            cmd.extend(["-map_metadata", "-1"])
457        else:
458            # map global metadata
459            cmd.extend(["-map_metadata", "0"])
460            # map per-stream metadata (e.g. language tags)
461            for stream_type in output_stream_types:
462                stream_key = stream_type[0]
463                if stream_type not in self.streams:
464                    continue
465                for idx, _ in enumerate(self.streams[stream_type].items()):
466                    cmd.extend(
467                        [
468                            f"-map_metadata:s:{stream_key}:{idx}",
469                            f"0:s:{stream_key}:{idx}",
470                        ]
471                    )
472
473        # map chapters if needed
474        if self.ffmpeg_normalize.chapters_disable:
475            cmd.extend(["-map_chapters", "-1"])
476        else:
477            cmd.extend(["-map_chapters", "0"])
478
479        # collect all '-map' and codecs needed for output video based on input video
480        if self.streams["video"]:
481            if self._can_write_output_video():
482                for s in self.streams["video"].keys():
483                    cmd.extend(["-map", f"0:{s}"])
484                # set codec (copy by default)
485                cmd.extend(["-c:v", self.ffmpeg_normalize.video_codec])
486            else:
487                if not self.ffmpeg_normalize.video_disable:
488                    _logger.warning(
489                        f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
490                    )
491
492        # ... and map the output of the normalization filters
493        for ol in output_labels:
494            cmd.extend(["-map", ol])
495
496        # set audio codec (never copy)
497        if self.ffmpeg_normalize.audio_codec:
498            cmd.extend(["-c:a", self.ffmpeg_normalize.audio_codec])
499        else:
500            for index, (_, audio_stream) in enumerate(self.streams["audio"].items()):
501                cmd.extend([f"-c:a:{index}", audio_stream.get_pcm_codec()])
502
503        # other audio options (if any)
504        if self.ffmpeg_normalize.audio_bitrate:
505            if self.ffmpeg_normalize.audio_codec == "libvorbis":
506                # libvorbis takes just a "-b" option, for some reason
507                # https://github.com/slhck/ffmpeg-normalize/issues/277
508                cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
509            else:
510                cmd.extend(["-b:a", str(self.ffmpeg_normalize.audio_bitrate)])
511        if self.ffmpeg_normalize.sample_rate:
512            cmd.extend(["-ar", str(self.ffmpeg_normalize.sample_rate)])
513        if self.ffmpeg_normalize.audio_channels:
514            cmd.extend(["-ac", str(self.ffmpeg_normalize.audio_channels)])
515
516        # ... and subtitles
517        if not self.ffmpeg_normalize.subtitle_disable:
518            for s in self.streams["subtitle"].keys():
519                cmd.extend(["-map", f"0:{s}"])
520            # copy subtitles
521            cmd.extend(["-c:s", "copy"])
522
523        if self.ffmpeg_normalize.keep_original_audio:
524            highest_index = len(self.streams["audio"])
525            for index, _ in enumerate(self.streams["audio"].items()):
526                cmd.extend(["-map", f"0:a:{index}"])
527                cmd.extend([f"-c:a:{highest_index + index}", "copy"])
528
529        # extra options (if any)
530        if self.ffmpeg_normalize.extra_output_options:
531            cmd.extend(self.ffmpeg_normalize.extra_output_options)
532
533        # output format (if any)
534        if self.ffmpeg_normalize.output_format:
535            cmd.extend(["-f", self.ffmpeg_normalize.output_format])
536
537        # if dry run, only show sample command
538        if self.ffmpeg_normalize.dry_run:
539            cmd.append(self.output_file)
540            _logger.warning("Dry run used, not actually running second-pass command")
541            CommandRunner(dry=True).run_command(cmd)
542            yield 100
543            return
544
545        # special case: if output is a null device, write directly to it
546        if self.output_file == os.devnull:
547            cmd.append(self.output_file)
548        else:
549            temp_dir = mkdtemp()
550            temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
551            cmd.append(temp_file)
552
553        cmd_runner = CommandRunner()
554        try:
555            try:
556                yield from cmd_runner.run_ffmpeg_command(cmd)
557            except Exception as e:
558                _logger.error(
559                    f"Error while running command {shlex.join(cmd)}! Error: {e}"
560                )
561                raise e
562            else:
563                if self.output_file != os.devnull:
564                    _logger.debug(
565                        f"Moving temporary file from {temp_file} to {self.output_file}"
566                    )
567                    move(temp_file, self.output_file)
568                    rmtree(temp_dir, ignore_errors=True)
569        except Exception as e:
570            if self.output_file != os.devnull:
571                rmtree(temp_dir, ignore_errors=True)
572            raise e
573
574        output = cmd_runner.get_output()
575        # in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
576        # overall output (which includes multiple loudnorm stats)
577        if self.ffmpeg_normalize.normalization_type == "ebu":
578            all_stats = AudioStream.prune_and_parse_loudnorm_output(output)
579            for stream_id, audio_stream in self.streams["audio"].items():
580                if stream_id in all_stats:
581                    audio_stream.set_second_pass_stats(all_stats[stream_id])
582
583        # warn if self.media_file.ffmpeg_normalize.dynamic == False and any of the second pass stats contain "normalization_type" == "dynamic"
584        if self.ffmpeg_normalize.dynamic is False:
585            for audio_stream in self.streams["audio"].values():
586                pass2_stats = audio_stream.get_stats()["ebu_pass2"]
587                if pass2_stats is None:
588                    continue
589                if pass2_stats["normalization_type"] == "dynamic":
590                    _logger.warning(
591                        "You specified linear normalization, but the loudnorm filter reverted to dynamic normalization. "
592                        "This may lead to unexpected results."
593                        "Consider your input settings, e.g. choose a lower target level or higher target loudness range."
594                    )
595
596        _logger.debug("Normalization finished")
597
598    def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
599        return (
600            audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
601        )

Class that holds a file, its streams and adjustments

MediaFile( ffmpeg_normalize: FFmpegNormalize, input_file: str, output_file: str)
61    def __init__(
62        self, ffmpeg_normalize: FFmpegNormalize, input_file: str, output_file: str
63    ):
64        """
65        Initialize a media file for later normalization by parsing the streams.
66
67        Args:
68            ffmpeg_normalize (FFmpegNormalize): reference to overall settings
69            input_file (str): Path to input file
70            output_file (str): Path to output file
71        """
72        self.ffmpeg_normalize = ffmpeg_normalize
73        self.skip = False
74        self.input_file = input_file
75        self.output_file = output_file
76        current_ext = os.path.splitext(output_file)[1][1:]
77        # we need to check if it's empty, e.g. /dev/null or NUL
78        if current_ext == "" or self.output_file == os.devnull:
79            self.output_ext = self.ffmpeg_normalize.extension
80        else:
81            self.output_ext = current_ext
82        self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
83
84        self.parse_streams()

Initialize a media file for later normalization by parsing the streams.

Arguments:
  • ffmpeg_normalize (FFmpegNormalize): reference to overall settings
  • input_file (str): Path to input file
  • output_file (str): Path to output file
ffmpeg_normalize
skip
input_file
output_file
streams: ffmpeg_normalize._media_file.StreamDict
def parse_streams(self) -> None:
102    def parse_streams(self) -> None:
103        """
104        Try to parse all input streams from file and set them in self.streams.
105
106        Raises:
107            FFmpegNormalizeError: If no audio streams are found
108        """
109        _logger.debug(f"Parsing streams of {self.input_file}")
110
111        cmd = [
112            self.ffmpeg_normalize.ffmpeg_exe,
113            "-i",
114            self.input_file,
115            "-c",
116            "copy",
117            "-t",
118            "0",
119            "-map",
120            "0",
121            "-f",
122            "null",
123            os.devnull,
124        ]
125
126        output = CommandRunner().run_command(cmd).get_output()
127
128        _logger.debug("Stream parsing command output:")
129        _logger.debug(output)
130
131        output_lines = [line.strip() for line in output.split("\n")]
132
133        duration = None
134        for line in output_lines:
135            if "Duration" in line:
136                if duration_search := DUR_REGEX.search(line):
137                    duration = _to_ms(**duration_search.groupdict()) / 1000
138                    _logger.debug(f"Found duration: {duration} s")
139                else:
140                    _logger.warning("Could not extract duration from input file!")
141
142            if not line.startswith("Stream"):
143                continue
144
145            if stream_id_match := re.search(r"#0:([\d]+)", line):
146                stream_id = int(stream_id_match.group(1))
147                if stream_id in self._stream_ids():
148                    continue
149            else:
150                continue
151
152            if "Audio" in line:
153                _logger.debug(f"Found audio stream at index {stream_id}")
154                sample_rate_match = re.search(r"(\d+) Hz", line)
155                sample_rate = (
156                    int(sample_rate_match.group(1)) if sample_rate_match else None
157                )
158                bit_depth_match = re.search(r"[sfu](\d+)(p|le|be)?", line)
159                bit_depth = int(bit_depth_match.group(1)) if bit_depth_match else None
160                self.streams["audio"][stream_id] = AudioStream(
161                    self.ffmpeg_normalize,
162                    self,
163                    stream_id,
164                    sample_rate,
165                    bit_depth,
166                    duration,
167                )
168
169            elif "Video" in line:
170                _logger.debug(f"Found video stream at index {stream_id}")
171                self.streams["video"][stream_id] = VideoStream(
172                    self.ffmpeg_normalize, self, stream_id
173                )
174
175            elif "Subtitle" in line:
176                _logger.debug(f"Found subtitle stream at index {stream_id}")
177                self.streams["subtitle"][stream_id] = SubtitleStream(
178                    self.ffmpeg_normalize, self, stream_id
179                )
180
181        if not self.streams["audio"]:
182            raise FFmpegNormalizeError(
183                f"Input file {self.input_file} does not contain any audio streams"
184            )
185
186        if (
187            self.output_ext.lower() in ONE_STREAM
188            and len(self.streams["audio"].values()) > 1
189        ):
190            _logger.warning(
191                "Output file only supports one stream. Keeping only first audio stream."
192            )
193            first_stream = list(self.streams["audio"].values())[0]
194            self.streams["audio"] = {first_stream.stream_id: first_stream}
195            self.streams["video"] = {}
196            self.streams["subtitle"] = {}

Try to parse all input streams from file and set them in self.streams.

Raises:
  • FFmpegNormalizeError: If no audio streams are found
def run_normalization(self) -> None:
198    def run_normalization(self) -> None:
199        """
200        Run the normalization process for this file.
201        """
202        _logger.debug(f"Running normalization for {self.input_file}")
203
204        # run the first pass to get loudness stats
205        self._first_pass()
206
207        # shortcut to apply replaygain
208        if self.ffmpeg_normalize.replaygain:
209            self._run_replaygain()
210            return
211
212        # run the second pass as a whole
213        if self.ffmpeg_normalize.progress:
214            with tqdm(
215                total=100,
216                position=1,
217                desc="Second Pass",
218                bar_format=TQDM_BAR_FORMAT,
219            ) as pbar:
220                for progress in self._second_pass():
221                    pbar.update(progress - pbar.n)
222        else:
223            for _ in self._second_pass():
224                pass
225
226        _logger.info(f"Normalized file written to {self.output_file}")

Run the normalization process for this file.

def get_stats( self) -> Iterable[ffmpeg_normalize._streams.LoudnessStatisticsWithMetadata]:
598    def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
599        return (
600            audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
601        )
class AudioStream(ffmpeg_normalize.MediaStream):
 91class AudioStream(MediaStream):
 92    def __init__(
 93        self,
 94        ffmpeg_normalize: FFmpegNormalize,
 95        media_file: MediaFile,
 96        stream_id: int,
 97        sample_rate: int | None,
 98        bit_depth: int | None,
 99        duration: float | None,
100    ):
101        """
102        Create an AudioStream object.
103
104        Args:
105            ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
106            media_file (MediaFile): The MediaFile object.
107            stream_id (int): The stream ID.
108            sample_rate (int): sample rate in Hz
109            bit_depth (int): bit depth in bits
110            duration (float): duration in seconds
111        """
112        super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
113
114        self.loudness_statistics: LoudnessStatistics = {
115            "ebu_pass1": None,
116            "ebu_pass2": None,
117            "mean": None,
118            "max": None,
119        }
120
121        self.sample_rate = sample_rate
122        self.bit_depth = bit_depth
123
124        self.duration = duration
125
126    @staticmethod
127    def _constrain(
128        number: float, min_range: float, max_range: float, name: str | None = None
129    ) -> float:
130        """
131        Constrain a number between two values.
132
133        Args:
134            number (float): The number to constrain.
135            min_range (float): The minimum value.
136            max_range (float): The maximum value.
137            name (str): The name of the number (for logging).
138
139        Returns:
140            float: The constrained number.
141
142        Raises:
143            ValueError: If min_range is greater than max_range.
144        """
145        if min_range > max_range:
146            raise ValueError("min must be smaller than max")
147        result = max(min(number, max_range), min_range)
148        if result != number and name is not None:
149            _logger.warning(
150                f"Constraining {name} to range of [{min_range}, {max_range}]: {number} -> {result}"
151            )
152        return result
153
154    def get_stats(self) -> LoudnessStatisticsWithMetadata:
155        """
156        Return loudness statistics for the stream.
157
158        Returns:
159            dict: A dictionary containing the loudness statistics.
160        """
161        stats: LoudnessStatisticsWithMetadata = {
162            "input_file": self.media_file.input_file,
163            "output_file": self.media_file.output_file,
164            "stream_id": self.stream_id,
165            "ebu_pass1": self.loudness_statistics["ebu_pass1"],
166            "ebu_pass2": self.loudness_statistics["ebu_pass2"],
167            "mean": self.loudness_statistics["mean"],
168            "max": self.loudness_statistics["max"],
169        }
170        return stats
171
172    def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
173        """
174        Set the EBU loudness statistics for the second pass.
175
176        Args:
177            stats (dict): The EBU loudness statistics.
178        """
179        self.loudness_statistics["ebu_pass2"] = stats
180
181    def get_pcm_codec(self) -> str:
182        """
183        Get the PCM codec string for the stream.
184
185        Returns:
186            str: The PCM codec string.
187        """
188        if not self.bit_depth:
189            return "pcm_s16le"
190        elif self.bit_depth <= 8:
191            return "pcm_s8"
192        elif self.bit_depth in [16, 24, 32, 64]:
193            return f"pcm_s{self.bit_depth}le"
194        else:
195            _logger.warning(
196                f"Unsupported bit depth {self.bit_depth}, falling back to pcm_s16le"
197            )
198            return "pcm_s16le"
199
200    def _get_filter_str_with_pre_filter(self, current_filter: str) -> str:
201        """
202        Get a filter string for current_filter, with the pre-filter
203        added before. Applies the input label before.
204
205        Args:
206            current_filter (str): The current filter.
207
208        Returns:
209            str: The filter string.
210        """
211        input_label = f"[0:{self.stream_id}]"
212        filter_chain = []
213        if self.media_file.ffmpeg_normalize.pre_filter:
214            filter_chain.append(self.media_file.ffmpeg_normalize.pre_filter)
215        filter_chain.append(current_filter)
216        filter_str = input_label + ",".join(filter_chain)
217        return filter_str
218
219    def parse_astats(self) -> Iterator[float]:
220        """
221        Use ffmpeg with astats filter to get the mean (RMS) and max (peak) volume of the input file.
222
223        Yields:
224            float: The progress of the command.
225        """
226        _logger.info(f"Running first pass astats filter for stream {self.stream_id}")
227
228        filter_str = self._get_filter_str_with_pre_filter(
229            "astats=measure_overall=Peak_level+RMS_level:measure_perchannel=0"
230        )
231
232        cmd = [
233            self.media_file.ffmpeg_normalize.ffmpeg_exe,
234            "-hide_banner",
235            "-y",
236            "-i",
237            self.media_file.input_file,
238            "-filter_complex",
239            filter_str,
240            "-vn",
241            "-sn",
242            "-f",
243            "null",
244            os.devnull,
245        ]
246
247        cmd_runner = CommandRunner()
248        yield from cmd_runner.run_ffmpeg_command(cmd)
249        output = cmd_runner.get_output()
250
251        _logger.debug(
252            f"astats command output: {CommandRunner.prune_ffmpeg_progress_from_output(output)}"
253        )
254
255        mean_volume_matches = re.findall(r"RMS level dB: ([\-\d\.]+)", output)
256        if mean_volume_matches:
257            if mean_volume_matches[0] == "-":
258                self.loudness_statistics["mean"] = float("-inf")
259            else:
260                self.loudness_statistics["mean"] = float(mean_volume_matches[0])
261        else:
262            raise FFmpegNormalizeError(
263                f"Could not get mean volume for {self.media_file.input_file}"
264            )
265
266        max_volume_matches = re.findall(r"Peak level dB: ([\-\d\.]+)", output)
267        if max_volume_matches:
268            if max_volume_matches[0] == "-":
269                self.loudness_statistics["max"] = float("-inf")
270            else:
271                self.loudness_statistics["max"] = float(max_volume_matches[0])
272        else:
273            raise FFmpegNormalizeError(
274                f"Could not get max volume for {self.media_file.input_file}"
275            )
276
277    def parse_loudnorm_stats(self) -> Iterator[float]:
278        """
279        Run a first pass loudnorm filter to get measured data.
280
281        Yields:
282            float: The progress of the command.
283        """
284        _logger.info(f"Running first pass loudnorm filter for stream {self.stream_id}")
285
286        opts = {
287            "i": self.media_file.ffmpeg_normalize.target_level,
288            "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
289            "tp": self.media_file.ffmpeg_normalize.true_peak,
290            "offset": self.media_file.ffmpeg_normalize.offset,
291            "print_format": "json",
292        }
293
294        if self.media_file.ffmpeg_normalize.dual_mono:
295            opts["dual_mono"] = "true"
296
297        filter_str = self._get_filter_str_with_pre_filter(
298            "loudnorm=" + dict_to_filter_opts(opts)
299        )
300
301        cmd = [
302            self.media_file.ffmpeg_normalize.ffmpeg_exe,
303            "-hide_banner",
304            "-y",
305            "-i",
306            self.media_file.input_file,
307            "-map",
308            f"0:{self.stream_id}",
309            "-filter_complex",
310            filter_str,
311            "-vn",
312            "-sn",
313            "-f",
314            "null",
315            os.devnull,
316        ]
317
318        cmd_runner = CommandRunner()
319        yield from cmd_runner.run_ffmpeg_command(cmd)
320        output = cmd_runner.get_output()
321
322        _logger.debug(
323            f"Loudnorm first pass command output: {CommandRunner.prune_ffmpeg_progress_from_output(output)}"
324        )
325
326        # only one stream
327        self.loudness_statistics["ebu_pass1"] = next(
328            iter(AudioStream.prune_and_parse_loudnorm_output(output).values())
329        )
330
331    @staticmethod
332    def prune_and_parse_loudnorm_output(
333        output: str,
334    ) -> dict[int, EbuLoudnessStatistics]:
335        """
336        Prune ffmpeg progress lines from output and parse the loudnorm filter output.
337        There may be multiple outputs if multiple streams were processed.
338
339        Args:
340            output (str): The output from ffmpeg.
341
342        Returns:
343            list: The EBU loudness statistics.
344        """
345        pruned_output = CommandRunner.prune_ffmpeg_progress_from_output(output)
346        output_lines = [line.strip() for line in pruned_output.split("\n")]
347        return AudioStream._parse_loudnorm_output(output_lines)
348
349    @staticmethod
350    def _parse_loudnorm_output(
351        output_lines: list[str],
352    ) -> dict[int, EbuLoudnessStatistics]:
353        """
354        Parse the output of a loudnorm filter to get the EBU loudness statistics.
355
356        Args:
357            output_lines (list[str]): The output lines of the loudnorm filter.
358
359        Raises:
360            FFmpegNormalizeError: When the output could not be parsed.
361
362        Returns:
363            EbuLoudnessStatistics: The EBU loudness statistics, if found.
364        """
365        result = dict[int, EbuLoudnessStatistics]()
366        stream_index = -1
367        loudnorm_start = 0
368        for index, line in enumerate(output_lines):
369            if stream_index < 0:
370                if m := _loudnorm_pattern.match(line):
371                    loudnorm_start = index + 1
372                    stream_index = int(m.group(1))
373            else:
374                if line.startswith("}"):
375                    loudnorm_end = index + 1
376                    loudnorm_data = "\n".join(output_lines[loudnorm_start:loudnorm_end])
377
378                    try:
379                        loudnorm_stats = json.loads(loudnorm_data)
380
381                        _logger.debug(
382                            f"Loudnorm stats for stream {stream_index} parsed: {loudnorm_data}"
383                        )
384
385                        for key in [
386                            "input_i",
387                            "input_tp",
388                            "input_lra",
389                            "input_thresh",
390                            "output_i",
391                            "output_tp",
392                            "output_lra",
393                            "output_thresh",
394                            "target_offset",
395                            "normalization_type",
396                        ]:
397                            if key not in loudnorm_stats:
398                                continue
399                            if key == "normalization_type":
400                                loudnorm_stats[key] = loudnorm_stats[key].lower()
401                            # handle infinite values
402                            elif float(loudnorm_stats[key]) == -float("inf"):
403                                loudnorm_stats[key] = -99
404                            elif float(loudnorm_stats[key]) == float("inf"):
405                                loudnorm_stats[key] = 0
406                            else:
407                                # convert to floats
408                                loudnorm_stats[key] = float(loudnorm_stats[key])
409
410                        result[stream_index] = cast(
411                            EbuLoudnessStatistics, loudnorm_stats
412                        )
413                        stream_index = -1
414                    except Exception as e:
415                        raise FFmpegNormalizeError(
416                            f"Could not parse loudnorm stats; wrong JSON format in string: {e}"
417                        )
418        return result
419
420    def get_second_pass_opts_ebu(self) -> str:
421        """
422        Return second pass loudnorm filter options string for ffmpeg
423        """
424
425        if not self.loudness_statistics["ebu_pass1"]:
426            raise FFmpegNormalizeError(
427                "First pass not run, you must call parse_loudnorm_stats first"
428            )
429
430        if float(self.loudness_statistics["ebu_pass1"]["input_i"]) > 0:
431            _logger.warning(
432                "Input file had measured input loudness greater than zero "
433                f"({self.loudness_statistics['ebu_pass1']['input_i']}), capping at 0"
434            )
435            self.loudness_statistics["ebu_pass1"]["input_i"] = 0
436
437        will_use_dynamic_mode = self.media_file.ffmpeg_normalize.dynamic
438
439        if self.media_file.ffmpeg_normalize.keep_loudness_range_target:
440            _logger.debug(
441                "Keeping target loudness range in second pass loudnorm filter"
442            )
443            input_lra = self.loudness_statistics["ebu_pass1"]["input_lra"]
444            if input_lra < 1 or input_lra > 50:
445                _logger.warning(
446                    "Input file had measured loudness range outside of [1,50] "
447                    f"({input_lra}), capping to allowed range"
448                )
449
450            self.media_file.ffmpeg_normalize.loudness_range_target = self._constrain(
451                self.loudness_statistics["ebu_pass1"]["input_lra"], 1, 50
452            )
453
454        if self.media_file.ffmpeg_normalize.keep_lra_above_loudness_range_target:
455            if (
456                self.loudness_statistics["ebu_pass1"]["input_lra"]
457                <= self.media_file.ffmpeg_normalize.loudness_range_target
458            ):
459                _logger.debug(
460                    "Setting loudness range target in second pass loudnorm filter"
461                )
462            else:
463                self.media_file.ffmpeg_normalize.loudness_range_target = (
464                    self.loudness_statistics["ebu_pass1"]["input_lra"]
465                )
466                _logger.debug(
467                    "Keeping target loudness range in second pass loudnorm filter"
468                )
469
470        if (
471            self.media_file.ffmpeg_normalize.loudness_range_target
472            < self.loudness_statistics["ebu_pass1"]["input_lra"]
473            and not will_use_dynamic_mode
474        ):
475            _logger.warning(
476                f"Input file had loudness range of {self.loudness_statistics['ebu_pass1']['input_lra']}. "
477                f"This is larger than the loudness range target ({self.media_file.ffmpeg_normalize.loudness_range_target}). "
478                "Normalization will revert to dynamic mode. Choose a higher target loudness range if you want linear normalization. "
479                "Alternatively, use the --keep-loudness-range-target or --keep-lra-above-loudness-range-target option to keep the target loudness range from "
480                "the input."
481            )
482            will_use_dynamic_mode = True
483
484        if will_use_dynamic_mode and not self.ffmpeg_normalize.sample_rate:
485            _logger.warning(
486                "In dynamic mode, the sample rate will automatically be set to 192 kHz by the loudnorm filter. "
487                "Specify -ar/--sample-rate to override it."
488            )
489
490        target_level = self.ffmpeg_normalize.target_level
491        if self.ffmpeg_normalize.auto_lower_loudness_target:
492            safe_target = (
493                self.loudness_statistics["ebu_pass1"]["input_i"]
494                - self.loudness_statistics["ebu_pass1"]["input_tp"]
495                + self.ffmpeg_normalize.true_peak
496                - 0.1
497            )
498            if safe_target < self.ffmpeg_normalize.target_level:
499                target_level = safe_target
500                _logger.warning(
501                    f"Using loudness target {target_level} because --auto-lower-loudness-target given.",
502                )
503
504        stats = self.loudness_statistics["ebu_pass1"]
505
506        opts = {
507            "i": target_level,
508            "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
509            "tp": self.media_file.ffmpeg_normalize.true_peak,
510            "offset": self._constrain(
511                stats["target_offset"], -99, 99, name="target_offset"
512            ),
513            "measured_i": self._constrain(stats["input_i"], -99, 0, name="input_i"),
514            "measured_lra": self._constrain(
515                stats["input_lra"], 0, 99, name="input_lra"
516            ),
517            "measured_tp": self._constrain(stats["input_tp"], -99, 99, name="input_tp"),
518            "measured_thresh": self._constrain(
519                stats["input_thresh"], -99, 0, name="input_thresh"
520            ),
521            "linear": "false" if self.media_file.ffmpeg_normalize.dynamic else "true",
522            "print_format": "json",
523        }
524
525        if self.media_file.ffmpeg_normalize.dual_mono:
526            opts["dual_mono"] = "true"
527
528        return "loudnorm=" + dict_to_filter_opts(opts)
529
530    def get_second_pass_opts_peakrms(self) -> str:
531        """
532        Set the adjustment gain based on chosen option and mean/max volume,
533        return the matching ffmpeg volume filter.
534
535        Returns:
536            str: ffmpeg volume filter string
537        """
538        if (
539            self.loudness_statistics["max"] is None
540            or self.loudness_statistics["mean"] is None
541        ):
542            raise FFmpegNormalizeError(
543                "First pass not run, no mean/max volume to normalize to"
544            )
545
546        normalization_type = self.media_file.ffmpeg_normalize.normalization_type
547        target_level = self.media_file.ffmpeg_normalize.target_level
548
549        if normalization_type == "peak":
550            adjustment = 0 + target_level - self.loudness_statistics["max"]
551        elif normalization_type == "rms":
552            adjustment = target_level - self.loudness_statistics["mean"]
553        else:
554            raise FFmpegNormalizeError(
555                "Can only set adjustment for peak and RMS normalization"
556            )
557
558        _logger.info(
559            f"Adjusting stream {self.stream_id} by {adjustment} dB to reach {target_level}"
560        )
561
562        clip_amount = self.loudness_statistics["max"] + adjustment
563        if clip_amount > 0:
564            _logger.warning(f"Adjusting will lead to clipping of {clip_amount} dB")
565
566        return f"volume={adjustment}dB"
AudioStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int, sample_rate: int | None, bit_depth: int | None, duration: float | None)
 92    def __init__(
 93        self,
 94        ffmpeg_normalize: FFmpegNormalize,
 95        media_file: MediaFile,
 96        stream_id: int,
 97        sample_rate: int | None,
 98        bit_depth: int | None,
 99        duration: float | None,
100    ):
101        """
102        Create an AudioStream object.
103
104        Args:
105            ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
106            media_file (MediaFile): The MediaFile object.
107            stream_id (int): The stream ID.
108            sample_rate (int): sample rate in Hz
109            bit_depth (int): bit depth in bits
110            duration (float): duration in seconds
111        """
112        super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
113
114        self.loudness_statistics: LoudnessStatistics = {
115            "ebu_pass1": None,
116            "ebu_pass2": None,
117            "mean": None,
118            "max": None,
119        }
120
121        self.sample_rate = sample_rate
122        self.bit_depth = bit_depth
123
124        self.duration = duration

Create an AudioStream object.

Arguments:
  • ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
  • media_file (MediaFile): The MediaFile object.
  • stream_id (int): The stream ID.
  • sample_rate (int): sample rate in Hz
  • bit_depth (int): bit depth in bits
  • duration (float): duration in seconds
loudness_statistics: ffmpeg_normalize._streams.LoudnessStatistics
sample_rate
bit_depth
duration
def get_stats(self) -> ffmpeg_normalize._streams.LoudnessStatisticsWithMetadata:
154    def get_stats(self) -> LoudnessStatisticsWithMetadata:
155        """
156        Return loudness statistics for the stream.
157
158        Returns:
159            dict: A dictionary containing the loudness statistics.
160        """
161        stats: LoudnessStatisticsWithMetadata = {
162            "input_file": self.media_file.input_file,
163            "output_file": self.media_file.output_file,
164            "stream_id": self.stream_id,
165            "ebu_pass1": self.loudness_statistics["ebu_pass1"],
166            "ebu_pass2": self.loudness_statistics["ebu_pass2"],
167            "mean": self.loudness_statistics["mean"],
168            "max": self.loudness_statistics["max"],
169        }
170        return stats

Return loudness statistics for the stream.

Returns:

dict: A dictionary containing the loudness statistics.

def set_second_pass_stats(self, stats: ffmpeg_normalize._streams.EbuLoudnessStatistics) -> None:
172    def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
173        """
174        Set the EBU loudness statistics for the second pass.
175
176        Args:
177            stats (dict): The EBU loudness statistics.
178        """
179        self.loudness_statistics["ebu_pass2"] = stats

Set the EBU loudness statistics for the second pass.

Arguments:
  • stats (dict): The EBU loudness statistics.
def get_pcm_codec(self) -> str:
181    def get_pcm_codec(self) -> str:
182        """
183        Get the PCM codec string for the stream.
184
185        Returns:
186            str: The PCM codec string.
187        """
188        if not self.bit_depth:
189            return "pcm_s16le"
190        elif self.bit_depth <= 8:
191            return "pcm_s8"
192        elif self.bit_depth in [16, 24, 32, 64]:
193            return f"pcm_s{self.bit_depth}le"
194        else:
195            _logger.warning(
196                f"Unsupported bit depth {self.bit_depth}, falling back to pcm_s16le"
197            )
198            return "pcm_s16le"

Get the PCM codec string for the stream.

Returns:

str: The PCM codec string.

def parse_astats(self) -> Iterator[float]:
219    def parse_astats(self) -> Iterator[float]:
220        """
221        Use ffmpeg with astats filter to get the mean (RMS) and max (peak) volume of the input file.
222
223        Yields:
224            float: The progress of the command.
225        """
226        _logger.info(f"Running first pass astats filter for stream {self.stream_id}")
227
228        filter_str = self._get_filter_str_with_pre_filter(
229            "astats=measure_overall=Peak_level+RMS_level:measure_perchannel=0"
230        )
231
232        cmd = [
233            self.media_file.ffmpeg_normalize.ffmpeg_exe,
234            "-hide_banner",
235            "-y",
236            "-i",
237            self.media_file.input_file,
238            "-filter_complex",
239            filter_str,
240            "-vn",
241            "-sn",
242            "-f",
243            "null",
244            os.devnull,
245        ]
246
247        cmd_runner = CommandRunner()
248        yield from cmd_runner.run_ffmpeg_command(cmd)
249        output = cmd_runner.get_output()
250
251        _logger.debug(
252            f"astats command output: {CommandRunner.prune_ffmpeg_progress_from_output(output)}"
253        )
254
255        mean_volume_matches = re.findall(r"RMS level dB: ([\-\d\.]+)", output)
256        if mean_volume_matches:
257            if mean_volume_matches[0] == "-":
258                self.loudness_statistics["mean"] = float("-inf")
259            else:
260                self.loudness_statistics["mean"] = float(mean_volume_matches[0])
261        else:
262            raise FFmpegNormalizeError(
263                f"Could not get mean volume for {self.media_file.input_file}"
264            )
265
266        max_volume_matches = re.findall(r"Peak level dB: ([\-\d\.]+)", output)
267        if max_volume_matches:
268            if max_volume_matches[0] == "-":
269                self.loudness_statistics["max"] = float("-inf")
270            else:
271                self.loudness_statistics["max"] = float(max_volume_matches[0])
272        else:
273            raise FFmpegNormalizeError(
274                f"Could not get max volume for {self.media_file.input_file}"
275            )

Use ffmpeg with astats filter to get the mean (RMS) and max (peak) volume of the input file.

Yields:

float: The progress of the command.

def parse_loudnorm_stats(self) -> Iterator[float]:
277    def parse_loudnorm_stats(self) -> Iterator[float]:
278        """
279        Run a first pass loudnorm filter to get measured data.
280
281        Yields:
282            float: The progress of the command.
283        """
284        _logger.info(f"Running first pass loudnorm filter for stream {self.stream_id}")
285
286        opts = {
287            "i": self.media_file.ffmpeg_normalize.target_level,
288            "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
289            "tp": self.media_file.ffmpeg_normalize.true_peak,
290            "offset": self.media_file.ffmpeg_normalize.offset,
291            "print_format": "json",
292        }
293
294        if self.media_file.ffmpeg_normalize.dual_mono:
295            opts["dual_mono"] = "true"
296
297        filter_str = self._get_filter_str_with_pre_filter(
298            "loudnorm=" + dict_to_filter_opts(opts)
299        )
300
301        cmd = [
302            self.media_file.ffmpeg_normalize.ffmpeg_exe,
303            "-hide_banner",
304            "-y",
305            "-i",
306            self.media_file.input_file,
307            "-map",
308            f"0:{self.stream_id}",
309            "-filter_complex",
310            filter_str,
311            "-vn",
312            "-sn",
313            "-f",
314            "null",
315            os.devnull,
316        ]
317
318        cmd_runner = CommandRunner()
319        yield from cmd_runner.run_ffmpeg_command(cmd)
320        output = cmd_runner.get_output()
321
322        _logger.debug(
323            f"Loudnorm first pass command output: {CommandRunner.prune_ffmpeg_progress_from_output(output)}"
324        )
325
326        # only one stream
327        self.loudness_statistics["ebu_pass1"] = next(
328            iter(AudioStream.prune_and_parse_loudnorm_output(output).values())
329        )

Run a first pass loudnorm filter to get measured data.

Yields:

float: The progress of the command.

@staticmethod
def prune_and_parse_loudnorm_output( output: str) -> dict[int, ffmpeg_normalize._streams.EbuLoudnessStatistics]:
331    @staticmethod
332    def prune_and_parse_loudnorm_output(
333        output: str,
334    ) -> dict[int, EbuLoudnessStatistics]:
335        """
336        Prune ffmpeg progress lines from output and parse the loudnorm filter output.
337        There may be multiple outputs if multiple streams were processed.
338
339        Args:
340            output (str): The output from ffmpeg.
341
342        Returns:
343            list: The EBU loudness statistics.
344        """
345        pruned_output = CommandRunner.prune_ffmpeg_progress_from_output(output)
346        output_lines = [line.strip() for line in pruned_output.split("\n")]
347        return AudioStream._parse_loudnorm_output(output_lines)

Prune ffmpeg progress lines from output and parse the loudnorm filter output. There may be multiple outputs if multiple streams were processed.

Arguments:
  • output (str): The output from ffmpeg.
Returns:

list: The EBU loudness statistics.

def get_second_pass_opts_ebu(self) -> str:
420    def get_second_pass_opts_ebu(self) -> str:
421        """
422        Return second pass loudnorm filter options string for ffmpeg
423        """
424
425        if not self.loudness_statistics["ebu_pass1"]:
426            raise FFmpegNormalizeError(
427                "First pass not run, you must call parse_loudnorm_stats first"
428            )
429
430        if float(self.loudness_statistics["ebu_pass1"]["input_i"]) > 0:
431            _logger.warning(
432                "Input file had measured input loudness greater than zero "
433                f"({self.loudness_statistics['ebu_pass1']['input_i']}), capping at 0"
434            )
435            self.loudness_statistics["ebu_pass1"]["input_i"] = 0
436
437        will_use_dynamic_mode = self.media_file.ffmpeg_normalize.dynamic
438
439        if self.media_file.ffmpeg_normalize.keep_loudness_range_target:
440            _logger.debug(
441                "Keeping target loudness range in second pass loudnorm filter"
442            )
443            input_lra = self.loudness_statistics["ebu_pass1"]["input_lra"]
444            if input_lra < 1 or input_lra > 50:
445                _logger.warning(
446                    "Input file had measured loudness range outside of [1,50] "
447                    f"({input_lra}), capping to allowed range"
448                )
449
450            self.media_file.ffmpeg_normalize.loudness_range_target = self._constrain(
451                self.loudness_statistics["ebu_pass1"]["input_lra"], 1, 50
452            )
453
454        if self.media_file.ffmpeg_normalize.keep_lra_above_loudness_range_target:
455            if (
456                self.loudness_statistics["ebu_pass1"]["input_lra"]
457                <= self.media_file.ffmpeg_normalize.loudness_range_target
458            ):
459                _logger.debug(
460                    "Setting loudness range target in second pass loudnorm filter"
461                )
462            else:
463                self.media_file.ffmpeg_normalize.loudness_range_target = (
464                    self.loudness_statistics["ebu_pass1"]["input_lra"]
465                )
466                _logger.debug(
467                    "Keeping target loudness range in second pass loudnorm filter"
468                )
469
470        if (
471            self.media_file.ffmpeg_normalize.loudness_range_target
472            < self.loudness_statistics["ebu_pass1"]["input_lra"]
473            and not will_use_dynamic_mode
474        ):
475            _logger.warning(
476                f"Input file had loudness range of {self.loudness_statistics['ebu_pass1']['input_lra']}. "
477                f"This is larger than the loudness range target ({self.media_file.ffmpeg_normalize.loudness_range_target}). "
478                "Normalization will revert to dynamic mode. Choose a higher target loudness range if you want linear normalization. "
479                "Alternatively, use the --keep-loudness-range-target or --keep-lra-above-loudness-range-target option to keep the target loudness range from "
480                "the input."
481            )
482            will_use_dynamic_mode = True
483
484        if will_use_dynamic_mode and not self.ffmpeg_normalize.sample_rate:
485            _logger.warning(
486                "In dynamic mode, the sample rate will automatically be set to 192 kHz by the loudnorm filter. "
487                "Specify -ar/--sample-rate to override it."
488            )
489
490        target_level = self.ffmpeg_normalize.target_level
491        if self.ffmpeg_normalize.auto_lower_loudness_target:
492            safe_target = (
493                self.loudness_statistics["ebu_pass1"]["input_i"]
494                - self.loudness_statistics["ebu_pass1"]["input_tp"]
495                + self.ffmpeg_normalize.true_peak
496                - 0.1
497            )
498            if safe_target < self.ffmpeg_normalize.target_level:
499                target_level = safe_target
500                _logger.warning(
501                    f"Using loudness target {target_level} because --auto-lower-loudness-target given.",
502                )
503
504        stats = self.loudness_statistics["ebu_pass1"]
505
506        opts = {
507            "i": target_level,
508            "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
509            "tp": self.media_file.ffmpeg_normalize.true_peak,
510            "offset": self._constrain(
511                stats["target_offset"], -99, 99, name="target_offset"
512            ),
513            "measured_i": self._constrain(stats["input_i"], -99, 0, name="input_i"),
514            "measured_lra": self._constrain(
515                stats["input_lra"], 0, 99, name="input_lra"
516            ),
517            "measured_tp": self._constrain(stats["input_tp"], -99, 99, name="input_tp"),
518            "measured_thresh": self._constrain(
519                stats["input_thresh"], -99, 0, name="input_thresh"
520            ),
521            "linear": "false" if self.media_file.ffmpeg_normalize.dynamic else "true",
522            "print_format": "json",
523        }
524
525        if self.media_file.ffmpeg_normalize.dual_mono:
526            opts["dual_mono"] = "true"
527
528        return "loudnorm=" + dict_to_filter_opts(opts)

Return second pass loudnorm filter options string for ffmpeg

def get_second_pass_opts_peakrms(self) -> str:
530    def get_second_pass_opts_peakrms(self) -> str:
531        """
532        Set the adjustment gain based on chosen option and mean/max volume,
533        return the matching ffmpeg volume filter.
534
535        Returns:
536            str: ffmpeg volume filter string
537        """
538        if (
539            self.loudness_statistics["max"] is None
540            or self.loudness_statistics["mean"] is None
541        ):
542            raise FFmpegNormalizeError(
543                "First pass not run, no mean/max volume to normalize to"
544            )
545
546        normalization_type = self.media_file.ffmpeg_normalize.normalization_type
547        target_level = self.media_file.ffmpeg_normalize.target_level
548
549        if normalization_type == "peak":
550            adjustment = 0 + target_level - self.loudness_statistics["max"]
551        elif normalization_type == "rms":
552            adjustment = target_level - self.loudness_statistics["mean"]
553        else:
554            raise FFmpegNormalizeError(
555                "Can only set adjustment for peak and RMS normalization"
556            )
557
558        _logger.info(
559            f"Adjusting stream {self.stream_id} by {adjustment} dB to reach {target_level}"
560        )
561
562        clip_amount = self.loudness_statistics["max"] + adjustment
563        if clip_amount > 0:
564            _logger.warning(f"Adjusting will lead to clipping of {clip_amount} dB")
565
566        return f"volume={adjustment}dB"

Set the adjustment gain based on chosen option and mean/max volume, return the matching ffmpeg volume filter.

Returns:

str: ffmpeg volume filter string

class VideoStream(ffmpeg_normalize.MediaStream):
77class VideoStream(MediaStream):
78    def __init__(
79        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
80    ):
81        super().__init__(ffmpeg_normalize, media_file, "video", stream_id)
VideoStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int)
78    def __init__(
79        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
80    ):
81        super().__init__(ffmpeg_normalize, media_file, "video", stream_id)

Create a MediaStream object.

Arguments:
  • ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
  • media_file (MediaFile): The MediaFile object.
  • stream_type (Literal["audio", "video", "subtitle"]): The type of the stream.
  • stream_id (int): The stream ID.
class SubtitleStream(ffmpeg_normalize.MediaStream):
84class SubtitleStream(MediaStream):
85    def __init__(
86        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
87    ):
88        super().__init__(ffmpeg_normalize, media_file, "subtitle", stream_id)
SubtitleStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int)
85    def __init__(
86        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
87    ):
88        super().__init__(ffmpeg_normalize, media_file, "subtitle", stream_id)

Create a MediaStream object.

Arguments:
  • ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
  • media_file (MediaFile): The MediaFile object.
  • stream_type (Literal["audio", "video", "subtitle"]): The type of the stream.
  • stream_id (int): The stream ID.
class MediaStream:
48class MediaStream:
49    def __init__(
50        self,
51        ffmpeg_normalize: FFmpegNormalize,
52        media_file: MediaFile,
53        stream_type: Literal["audio", "video", "subtitle"],
54        stream_id: int,
55    ):
56        """
57        Create a MediaStream object.
58
59        Args:
60            ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
61            media_file (MediaFile): The MediaFile object.
62            stream_type (Literal["audio", "video", "subtitle"]): The type of the stream.
63            stream_id (int): The stream ID.
64        """
65        self.ffmpeg_normalize = ffmpeg_normalize
66        self.media_file = media_file
67        self.stream_type = stream_type
68        self.stream_id = stream_id
69
70    def __repr__(self) -> str:
71        return (
72            f"<{os.path.basename(self.media_file.input_file)}, "
73            f"{self.stream_type} stream {self.stream_id}>"
74        )
MediaStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_type: Literal['audio', 'video', 'subtitle'], stream_id: int)
49    def __init__(
50        self,
51        ffmpeg_normalize: FFmpegNormalize,
52        media_file: MediaFile,
53        stream_type: Literal["audio", "video", "subtitle"],
54        stream_id: int,
55    ):
56        """
57        Create a MediaStream object.
58
59        Args:
60            ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
61            media_file (MediaFile): The MediaFile object.
62            stream_type (Literal["audio", "video", "subtitle"]): The type of the stream.
63            stream_id (int): The stream ID.
64        """
65        self.ffmpeg_normalize = ffmpeg_normalize
66        self.media_file = media_file
67        self.stream_type = stream_type
68        self.stream_id = stream_id

Create a MediaStream object.

Arguments:
  • ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
  • media_file (MediaFile): The MediaFile object.
  • stream_type (Literal["audio", "video", "subtitle"]): The type of the stream.
  • stream_id (int): The stream ID.
ffmpeg_normalize
media_file
stream_type
stream_id
__version__ = '1.31.3'