ffmpeg_normalize API documentation

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

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            _logger.debug(
    80                f"Current extension is unset, or output file is a null device, using extension: {self.ffmpeg_normalize.extension}"
    81            )
    82            self.output_ext = self.ffmpeg_normalize.extension
    83        else:
    84            _logger.debug(
    85                f"Current extension is set from output file, using extension: {current_ext}"
    86            )
    87            self.output_ext = current_ext
    88        self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
    89        self.temp_file: Union[str, None] = None
    90
    91        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
temp_file: Optional[str]
def parse_streams(self) -> None: View Source

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

    205    def run_normalization(self) -> None:
    206        """
    207        Run the normalization process for this file.
    208        """
    209        _logger.debug(f"Running normalization for {self.input_file}")
    210
    211        # run the first pass to get loudness stats
    212        self._first_pass()
    213
    214        # for second pass, create a temp file
    215        temp_dir = mkdtemp()
    216        self.temp_file = os.path.join(temp_dir, f"out.{self.output_ext}")
    217
    218        if self.ffmpeg_normalize.replaygain:
    219            _logger.debug(
    220                "ReplayGain mode: Second pass will run with temporary file to get stats."
    221            )
    222            self.output_file = self.temp_file
    223
    224        # run the second pass as a whole.
    225        if self.ffmpeg_normalize.progress:
    226            with tqdm(
    227                total=100,
    228                position=1,
    229                desc="Second Pass",
    230                bar_format=TQDM_BAR_FORMAT,
    231            ) as pbar:
    232                for progress in self._second_pass():
    233                    pbar.update(progress - pbar.n)
    234        else:
    235            for _ in self._second_pass():
    236                pass
    237
    238        # remove temp dir; this will remove the temp file as well if it has not been renamed (e.g. for replaygain)
    239        if os.path.exists(temp_dir):
    240            rmtree(temp_dir, ignore_errors=True)
    241
    242        # This will use stats from ebu_pass2 if available (from the main second pass),
    243        # or fall back to ebu_pass1.
    244        if self.ffmpeg_normalize.replaygain:
    245            _logger.debug(
    246                "ReplayGain tagging is enabled. Proceeding with tag calculation/application."
    247            )
    248            self._run_replaygain()
    249
    250        if not self.ffmpeg_normalize.replaygain:
    251            _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

    647    def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
    648        return (
    649            audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
    650        )

class AudioStream(ffmpeg_normalize.MediaStream): View Source

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

     95    def __init__(
     96        self,
     97        ffmpeg_normalize: FFmpegNormalize,
     98        media_file: MediaFile,
     99        stream_id: int,
    100        sample_rate: int | None,
    101        bit_depth: int | None,
    102        duration: float | None,
    103    ):
    104        """
    105        Create an AudioStream object.
    106
    107        Args:
    108            ffmpeg_normalize (FFmpegNormalize): The FFmpegNormalize object.
    109            media_file (MediaFile): The MediaFile object.
    110            stream_id (int): The stream ID.
    111            sample_rate (int): sample rate in Hz
    112            bit_depth (int): bit depth in bits
    113            duration (float): duration in seconds
    114        """
    115        super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
    116
    117        self.loudness_statistics: LoudnessStatistics = {
    118            "ebu_pass1": None,
    119            "ebu_pass2": None,
    120            "mean": None,
    121            "max": None,
    122        }
    123
    124        self.sample_rate = sample_rate
    125        self.bit_depth = bit_depth
    126
    127        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

    157    def get_stats(self) -> LoudnessStatisticsWithMetadata:
    158        """
    159        Return loudness statistics for the stream.
    160
    161        Returns:
    162            dict: A dictionary containing the loudness statistics.
    163        """
    164        stats: LoudnessStatisticsWithMetadata = {
    165            "input_file": self.media_file.input_file,
    166            "output_file": self.media_file.output_file,
    167            "stream_id": self.stream_id,
    168            "ebu_pass1": self.loudness_statistics["ebu_pass1"],
    169            "ebu_pass2": self.loudness_statistics["ebu_pass2"],
    170            "mean": self.loudness_statistics["mean"],
    171            "max": self.loudness_statistics["max"],
    172        }
    173        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

    175    def set_second_pass_stats(self, stats: EbuLoudnessStatistics) -> None:
    176        """
    177        Set the EBU loudness statistics for the second pass.
    178
    179        Args:
    180            stats (dict): The EBU loudness statistics.
    181        """
    182        _logger.debug(
    183            f"Setting second pass stats for stream {self.stream_id} from {stats}"
    184        )
    185        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

    187    def get_pcm_codec(self) -> str:
    188        """
    189        Get the PCM codec string for the stream.
    190
    191        Returns:
    192            str: The PCM codec string.
    193        """
    194        if not self.bit_depth:
    195            return "pcm_s16le"
    196        elif self.bit_depth <= 8:
    197            return "pcm_s8"
    198        elif self.bit_depth in [16, 24, 32, 64]:
    199            return f"pcm_s{self.bit_depth}le"
    200        else:
    201            _logger.warning(
    202                f"Unsupported bit depth {self.bit_depth}, falling back to pcm_s16le"
    203            )
    204            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

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

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

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

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

    337    @staticmethod
    338    def prune_and_parse_loudnorm_output(
    339        output: str,
    340    ) -> dict[int, EbuLoudnessStatistics]:
    341        """
    342        Prune ffmpeg progress lines from output and parse the loudnorm filter output.
    343        There may be multiple outputs if multiple streams were processed.
    344
    345        Args:
    346            output (str): The output from ffmpeg.
    347
    348        Returns:
    349            dict[int, EbuLoudnessStatistics]: The EBU loudness statistics.
    350        """
    351        _logger.debug("Parsing loudnorm stats from output")
    352        pruned_output = CommandRunner.prune_ffmpeg_progress_from_output(output)
    353        output_lines = [line.strip() for line in pruned_output.split("\n")]
    354        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:

> dict[int, EbuLoudnessStatistics]: The EBU loudness statistics.

def get_second_pass_opts_ebu(self) -> str: View Source

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

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

    80class VideoStream(MediaStream):
    81    def __init__(
    82        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
    83    ):
    84        super().__init__(ffmpeg_normalize, media_file, "video", stream_id)

VideoStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int) View Source

    81    def __init__(
    82        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
    83    ):
    84        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

    87class SubtitleStream(MediaStream):
    88    def __init__(
    89        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
    90    ):
    91        super().__init__(ffmpeg_normalize, media_file, "subtitle", stream_id)

SubtitleStream( ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int) View Source

    88    def __init__(
    89        self, ffmpeg_normalize: FFmpegNormalize, media_file: MediaFile, stream_id: int
    90    ):
    91        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        _logger.debug(
    70            f"Created MediaStream for {self.media_file.input_file}, {self.stream_type} stream {self.stream_id}"
    71        )
    72
    73    def __repr__(self) -> str:
    74        return (
    75            f"<{os.path.basename(self.media_file.input_file)}, "
    76            f"{self.stream_type} stream {self.stream_id}>"
    77        )

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
    69        _logger.debug(
    70            f"Created MediaStream for {self.media_file.input_file}, {self.stream_type} stream {self.stream_id}"
    71        )

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