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