# ffmpeg_normalize

View Source

     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: View Source

     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) View Source

     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: View Source

    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: View Source

    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): View Source

    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: View Source

     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        if not self.ffmpeg_normalize.dry_run:
    267            self._write_replaygain_tags(track_gain, track_peak)
    268        else:
    269            _logger.warning("Dry run used, not actually writing replaygain tags")
    270
    271    def _write_replaygain_tags(self, track_gain: float, track_peak: float) -> None:
    272        """
    273        Write the replaygain tags to the input file.
    274
    275        This is based on the code from bohning/usdb_syncer, licensed under the MIT license.
    276        See: https://github.com/bohning/usdb_syncer/blob/2fa638c4f487dffe9f5364f91e156ba54cb20233/src/usdb_syncer/resource_dl.py
    277        """
    278        _logger.debug(f"Writing ReplayGain tags to {self.input_file}")
    279
    280        input_file_ext = os.path.splitext(self.input_file)[1]
    281        if input_file_ext == ".mp3":
    282            mp3 = MP3(self.input_file, ID3=ID3)
    283            if not mp3.tags:
    284                return
    285            mp3.tags.add(
    286                TXXX(desc="REPLAYGAIN_TRACK_GAIN", text=[f"{track_gain:.2f} dB"])
    287            )
    288            mp3.tags.add(TXXX(desc="REPLAYGAIN_TRACK_PEAK", text=[f"{track_peak:.6f}"]))
    289            mp3.save()
    290        elif input_file_ext in [".mp4", ".m4a", ".m4v", ".mov"]:
    291            mp4 = MP4(self.input_file)
    292            if not mp4.tags:
    293                mp4.add_tags()
    294            if not mp4.tags:
    295                return
    296            mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = [
    297                f"{track_gain:.2f} dB".encode()
    298            ]
    299            mp4.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = [
    300                f"{track_peak:.6f}".encode()
    301            ]
    302            mp4.save()
    303        elif input_file_ext == ".ogg":
    304            ogg = OggVorbis(self.input_file)
    305            ogg["REPLAYGAIN_TRACK_GAIN"] = [f"{track_gain:.2f} dB"]
    306            ogg["REPLAYGAIN_TRACK_PEAK"] = [f"{track_peak:.6f}"]
    307            ogg.save()
    308        elif input_file_ext == ".opus":
    309            opus = OggOpus(self.input_file)
    310            # See https://datatracker.ietf.org/doc/html/rfc7845#section-5.2.1
    311            opus["R128_TRACK_GAIN"] = [str(round(256 * track_gain))]
    312            opus.save()
    313        else:
    314            _logger.error(
    315                f"Unsupported input file extension: {input_file_ext} for writing replaygain tags. "
    316                "Only .mp3, .mp4/.m4a, .ogg, .opus are supported. "
    317                "If you think this should support more formats, please let me know at "
    318                "https://github.com/slhck/ffmpeg-normalize/issues"
    319            )
    320            return
    321
    322        _logger.info(
    323            f"Successfully wrote replaygain tags to input file {self.input_file}"
    324        )
    325
    326    def _can_write_output_video(self) -> bool:
    327        """
    328        Determine whether the output file can contain video at all.
    329
    330        Returns:
    331            bool: True if the output file can contain video, False otherwise
    332        """
    333        if self.output_ext.lower() in AUDIO_ONLY_FORMATS:
    334            return False
    335
    336        return not self.ffmpeg_normalize.video_disable
    337
    338    def _first_pass(self) -> None:
    339        """
    340        Run the first pass of the normalization process.
    341        """
    342        _logger.debug(f"Parsing normalization info for {self.input_file}")
    343
    344        for index, audio_stream in enumerate(self.streams["audio"].values()):
    345            if self.ffmpeg_normalize.normalization_type == "ebu":
    346                fun = getattr(audio_stream, "parse_loudnorm_stats")
    347            else:
    348                fun = getattr(audio_stream, "parse_astats")
    349
    350            if self.ffmpeg_normalize.progress:
    351                with tqdm(
    352                    total=100,
    353                    position=1,
    354                    desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
    355                    bar_format=TQDM_BAR_FORMAT,
    356                ) as pbar:
    357                    for progress in fun():
    358                        pbar.update(progress - pbar.n)
    359            else:
    360                for _ in fun():
    361                    pass
    362
    363    def _get_audio_filter_cmd(self) -> tuple[str, list[str]]:
    364        """
    365        Return the audio filter command and output labels needed.
    366
    367        Returns:
    368            tuple[str, list[str]]: filter_complex command and the required output labels
    369        """
    370        filter_chains = []
    371        output_labels = []
    372
    373        for audio_stream in self.streams["audio"].values():
    374            skip_normalization = False
    375            if self.ffmpeg_normalize.lower_only:
    376                if self.ffmpeg_normalize.normalization_type == "ebu":
    377                    if (
    378                        audio_stream.loudness_statistics["ebu_pass1"] is not None
    379                        and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
    380                        < self.ffmpeg_normalize.target_level
    381                    ):
    382                        skip_normalization = True
    383                elif self.ffmpeg_normalize.normalization_type == "peak":
    384                    if (
    385                        audio_stream.loudness_statistics["max"] is not None
    386                        and audio_stream.loudness_statistics["max"]
    387                        < self.ffmpeg_normalize.target_level
    388                    ):
    389                        skip_normalization = True
    390                elif self.ffmpeg_normalize.normalization_type == "rms":
    391                    if (
    392                        audio_stream.loudness_statistics["mean"] is not None
    393                        and audio_stream.loudness_statistics["mean"]
    394                        < self.ffmpeg_normalize.target_level
    395                    ):
    396                        skip_normalization = True
    397
    398            if skip_normalization:
    399                _logger.warning(
    400                    f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
    401                )
    402                normalization_filter = "acopy"
    403            else:
    404                if self.ffmpeg_normalize.normalization_type == "ebu":
    405                    normalization_filter = audio_stream.get_second_pass_opts_ebu()
    406                else:
    407                    normalization_filter = audio_stream.get_second_pass_opts_peakrms()
    408
    409            input_label = f"[0:{audio_stream.stream_id}]"
    410            output_label = f"[norm{audio_stream.stream_id}]"
    411            output_labels.append(output_label)
    412
    413            filter_chain = []
    414
    415            if self.ffmpeg_normalize.pre_filter:
    416                filter_chain.append(self.ffmpeg_normalize.pre_filter)
    417
    418            filter_chain.append(normalization_filter)
    419
    420            if self.ffmpeg_normalize.post_filter:
    421                filter_chain.append(self.ffmpeg_normalize.post_filter)
    422
    423            filter_chains.append(input_label + ",".join(filter_chain) + output_label)
    424
    425        filter_complex_cmd = ";".join(filter_chains)
    426
    427        return filter_complex_cmd, output_labels
    428
    429    def _second_pass(self) -> Iterator[float]:
    430        """
    431        Construct the second pass command and run it.
    432
    433        FIXME: make this method simpler
    434        """
    435        _logger.info(f"Running second pass for {self.input_file}")
    436
    437        # get the target output stream types depending on the options
    438        output_stream_types: list[Literal["audio", "video", "subtitle"]] = ["audio"]
    439        if self._can_write_output_video():
    440            output_stream_types.append("video")
    441        if not self.ffmpeg_normalize.subtitle_disable:
    442            output_stream_types.append("subtitle")
    443
    444        # base command, here we will add all other options
    445        cmd = [self.ffmpeg_normalize.ffmpeg_exe, "-hide_banner", "-y"]
    446
    447        # extra options (if any)
    448        if self.ffmpeg_normalize.extra_input_options:
    449            cmd.extend(self.ffmpeg_normalize.extra_input_options)
    450
    451        # get complex filter command
    452        audio_filter_cmd, output_labels = self._get_audio_filter_cmd()
    453
    454        # add input file and basic filter
    455        cmd.extend(["-i", self.input_file, "-filter_complex", audio_filter_cmd])
    456
    457        # map metadata, only if needed
    458        if self.ffmpeg_normalize.metadata_disable:
    459            cmd.extend(["-map_metadata", "-1"])
    460        else:
    461            # map global metadata
    462            cmd.extend(["-map_metadata", "0"])
    463            # map per-stream metadata (e.g. language tags)
    464            for stream_type in output_stream_types:
    465                stream_key = stream_type[0]
    466                if stream_type not in self.streams:
    467                    continue
    468                for idx, _ in enumerate(self.streams[stream_type].items()):
    469                    cmd.extend(
    470                        [
    471                            f"-map_metadata:s:{stream_key}:{idx}",
    472                            f"0:s:{stream_key}:{idx}",
    473                        ]
    474                    )
    475
    476        # map chapters if needed
    477        if self.ffmpeg_normalize.chapters_disable:
    478            cmd.extend(["-map_chapters", "-1"])
    479        else:
    480            cmd.extend(["-map_chapters", "0"])
    481
    482        # collect all '-map' and codecs needed for output video based on input video
    483        if self.streams["video"]:
    484            if self._can_write_output_video():
    485                for s in self.streams["video"].keys():
    486                    cmd.extend(["-map", f"0:{s}"])
    487                # set codec (copy by default)
    488                cmd.extend(["-c:v", self.ffmpeg_normalize.video_codec])
    489            else:
    490                if not self.ffmpeg_normalize.video_disable:
    491                    _logger.warning(
    492                        f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
    493                    )
    494
    495        # ... and map the output of the normalization filters
    496        for ol in output_labels:
    497            cmd.extend(["-map", ol])
    498
    499        # set audio codec (never copy)
    500        if self.ffmpeg_normalize.audio_codec:
    501            cmd.extend(["-c:a", self.ffmpeg_normalize.audio_codec])
    502        else:
    503            for index, (_, audio_stream) in enumerate(self.streams["audio"].items()):
    504                cmd.extend([f"-c:a:{index}", audio_stream.get_pcm_codec()])
    505
    506        # other audio options (if any)
    507        if self.ffmpeg_normalize.audio_bitrate:
    508            if self.ffmpeg_normalize.audio_codec == "libvorbis":
    509                # libvorbis takes just a "-b" option, for some reason
    510                # https://github.com/slhck/ffmpeg-normalize/issues/277
    511                cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
    512            else:
    513                cmd.extend(["-b:a", str(self.ffmpeg_normalize.audio_bitrate)])
    514        if self.ffmpeg_normalize.sample_rate:
    515            cmd.extend(["-ar", str(self.ffmpeg_normalize.sample_rate)])
    516        if self.ffmpeg_normalize.audio_channels:
    517            cmd.extend(["-ac", str(self.ffmpeg_normalize.audio_channels)])
    518
    519        # ... and subtitles
    520        if not self.ffmpeg_normalize.subtitle_disable:
    521            for s in self.streams["subtitle"].keys():
    522                cmd.extend(["-map", f"0:{s}"])
    523            # copy subtitles
    524            cmd.extend(["-c:s", "copy"])
    525
    526        if self.ffmpeg_normalize.keep_original_audio:
    527            highest_index = len(self.streams["audio"])
    528            for index, _ in enumerate(self.streams["audio"].items()):
    529                cmd.extend(["-map", f"0:a:{index}"])
    530                cmd.extend([f"-c:a:{highest_index + index}", "copy"])
    531
    532        # extra options (if any)
    533        if self.ffmpeg_normalize.extra_output_options:
    534            cmd.extend(self.ffmpeg_normalize.extra_output_options)
    535
    536        # output format (if any)
    537        if self.ffmpeg_normalize.output_format:
    538            cmd.extend(["-f", self.ffmpeg_normalize.output_format])
    539
    540        # if dry run, only show sample command
    541        if self.ffmpeg_normalize.dry_run:
    542            cmd.append(self.output_file)
    543            _logger.warning("Dry run used, not actually running second-pass command")
    544            CommandRunner(dry=True).run_command(cmd)
    545            yield 100
    546            return
    547
    548        # special case: if output is a null device, write directly to it
    549        if self.output_file == os.devnull:
    550            cmd.append(self.output_file)
    551        else:
    552            temp_dir = mkdtemp()
    553            temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
    554            cmd.append(temp_file)
    555
    556        cmd_runner = CommandRunner()
    557        try:
    558            try:
    559                yield from cmd_runner.run_ffmpeg_command(cmd)
    560            except Exception as e:
    561                _logger.error(
    562                    f"Error while running command {shlex.join(cmd)}! Error: {e}"
    563                )
    564                raise e
    565            else:
    566                if self.output_file != os.devnull:
    567                    _logger.debug(
    568                        f"Moving temporary file from {temp_file} to {self.output_file}"
    569                    )
    570                    move(temp_file, self.output_file)
    571                    rmtree(temp_dir, ignore_errors=True)
    572        except Exception as e:
    573            if self.output_file != os.devnull:
    574                rmtree(temp_dir, ignore_errors=True)
    575            raise e
    576
    577        output = cmd_runner.get_output()
    578        # in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
    579        # overall output (which includes multiple loudnorm stats)
    580        if self.ffmpeg_normalize.normalization_type == "ebu":
    581            all_stats = AudioStream.prune_and_parse_loudnorm_output(output)
    582            for stream_id, audio_stream in self.streams["audio"].items():
    583                if stream_id in all_stats:
    584                    audio_stream.set_second_pass_stats(all_stats[stream_id])
    585
    586        # warn if self.media_file.ffmpeg_normalize.dynamic == False and any of the second pass stats contain "normalization_type" == "dynamic"
    587        if self.ffmpeg_normalize.dynamic is False:
    588            for audio_stream in self.streams["audio"].values():
    589                pass2_stats = audio_stream.get_stats()["ebu_pass2"]
    590                if pass2_stats is None:
    591                    continue
    592                if pass2_stats["normalization_type"] == "dynamic":
    593                    _logger.warning(
    594                        "You specified linear normalization, but the loudnorm filter reverted to dynamic normalization. "
    595                        "This may lead to unexpected results."
    596                        "Consider your input settings, e.g. choose a lower target level or higher target loudness range."
    597                    )
    598
    599        _logger.debug("Normalization finished")
    600
    601    def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
    602        return (
    603            audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
    604        )

Class that holds a file, its streams and adjustments

MediaFile( ffmpeg_normalize: FFmpegNormalize, input_file: str, output_file: str) View Source

    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: View Source

    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: View Source

    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]: View Source

    601    def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
    602        return (
    603            audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
    604        )

class AudioStream(ffmpeg_normalize.MediaStream): View Source

     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) View Source

     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: View Source

    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: View Source

    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: View Source

    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]: View Source

    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]: View Source

    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]: View Source

    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: View Source

    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: View Source

    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

##### Inherited Members

MediaStream
  ffmpeg_normalize
  media_file
  stream_type
  stream_id

class VideoStream(ffmpeg_normalize.MediaStream): View Source

    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) View Source

    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.

##### Inherited Members

MediaStream
  ffmpeg_normalize
  media_file
  stream_type
  stream_id

class SubtitleStream(ffmpeg_normalize.MediaStream): View Source

    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) View Source

    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.

##### Inherited Members

MediaStream
  ffmpeg_normalize
  media_file
  stream_type
  stream_id

class MediaStream: View Source

    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) View Source

    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.32.0'