diff --git a/ytsage/core/ytsage_downloader.py b/ytsage/core/ytsage_downloader.py index 0647152..a66d9a0 100644 --- a/ytsage/core/ytsage_downloader.py +++ b/ytsage/core/ytsage_downloader.py @@ -72,6 +72,7 @@ class DownloadThread(QThread): preferred_output_format="mp4", force_audio_format=False, preferred_audio_format="best", + audio_normalization=False, filename_format=None, ) -> None: super().__init__() @@ -100,6 +101,7 @@ class DownloadThread(QThread): self.preferred_output_format = preferred_output_format self.force_audio_format = force_audio_format self.preferred_audio_format = preferred_audio_format + self.audio_normalization = audio_normalization self.filename_format = filename_format self.paused: bool = False self.cancelled: bool = False @@ -271,6 +273,22 @@ class DownloadThread(QThread): logger.debug(f"Using --extract-audio with --audio-format {self.preferred_audio_format} for audio-only download") else: logger.debug("Using --extract-audio with best quality (no conversion) for audio-only download") + + # Add Audio Normalization if enabled (only applies to audio-only downloads) + if self.audio_normalization and self.is_audio_only: + # Normalization using FFmpeg filters requires re-encoding the audio stream. + # If the user selected "Best (No conversion)", yt-dlp attempts to stream copy (-c:a copy), + # which will cause FFmpeg to crash with "Invalid argument". + # We fix this by forcing an explicit actual conversion (mp3) if no format was forced. + if not self.force_audio_format or self.preferred_audio_format == "best": + if "--extract-audio" not in cmd: + cmd.append("--extract-audio") + cmd.extend(["--audio-format", "mp3"]) + logger.debug("Forced audio format to mp3 since normalization requires re-encoding") + + # Scope the argument specifically to ExtractAudio so it doesn't conflict with other PPs + cmd.extend(["--postprocessor-args", "ExtractAudio:-af loudnorm=I=-16:LRA=11:TP=-1.5"]) + logger.debug("Added Audio Normalization (--postprocessor-args ExtractAudio:-af loudnorm=...)") # Output template with resolution in filename # Use string concatenation instead of Path.joinpath to avoid Path object issues diff --git a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py index 6e78e6a..e2c1705 100644 --- a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py +++ b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py @@ -261,12 +261,24 @@ class DownloadSettingsDialog(QDialog): # Load current audio format settings from ConfigManager self.force_audio_format_enabled = ConfigManager.get("force_audio_format") or False self.preferred_audio_format_value = ConfigManager.get("preferred_audio_format") or "best" + self.audio_normalization_enabled = ConfigManager.get("audio_normalization") or False # Enable/Disable force audio format checkbox self.force_audio_format_checkbox = QCheckBox(_("settings.force_audio_format")) self.force_audio_format_checkbox.setChecked(self.force_audio_format_enabled) audio_format_layout.addWidget(self.force_audio_format_checkbox) + # Enable/Disable audio normalization checkbox + self.audio_normalization_checkbox = QCheckBox(_("settings.audio_normalization", default="Audio Normalization")) + self.audio_normalization_checkbox.setChecked(self.audio_normalization_enabled) + audio_format_layout.addWidget(self.audio_normalization_checkbox) + + # Audio normalization help text + audio_norm_help_label = QLabel(_("settings.audio_normalization_help", default="When enabled, audio will be normalized using EBU R128 standard.")) + audio_norm_help_label.setWordWrap(True) + audio_norm_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;") + audio_format_layout.addWidget(audio_norm_help_label) + # Audio format selection layout audio_format_select_layout = QHBoxLayout() audio_format_label = QLabel(_("settings.preferred_audio_format")) @@ -297,6 +309,10 @@ class DownloadSettingsDialog(QDialog): audio_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;") audio_format_layout.addWidget(audio_help_label) + # Connect signals + self.audio_normalization_checkbox.stateChanged.connect(self._on_audio_normalization_toggled) + self.force_audio_format_checkbox.stateChanged.connect(self._on_force_audio_format_toggled) + audio_format_group_box.setLayout(audio_format_layout) layout.addWidget(audio_format_group_box) @@ -337,6 +353,22 @@ class DownloadSettingsDialog(QDialog): button_box.rejected.connect(self.reject) layout.addWidget(button_box) + def _on_audio_normalization_toggled(self, state: int) -> None: + """Handle logic when audio normalization is toggled.""" + if state == Qt.CheckState.Checked.value: + # Normalization requires re-encoding, so we must force an audio format + self.force_audio_format_checkbox.setChecked(True) + + # If 'Best (No conversion)' is selected, change it to MP3 to ensure re-encoding + if self.audio_format_combo.currentIndex() == 0: + self.audio_format_combo.setCurrentIndex(2) # Index 2 is typically MP3 + + def _on_force_audio_format_toggled(self, state: int) -> None: + """Handle logic when force audio format is toggled.""" + if state == Qt.CheckState.Unchecked.value: + # If re-encoding is disabled, normalization cannot happen + self.audio_normalization_checkbox.setChecked(False) + def browse_new_path(self) -> None: new_path = QFileDialog.getExistingDirectory(self, _("dialogs.select_folder"), str(self.current_path)) if new_path: @@ -385,6 +417,10 @@ class DownloadSettingsDialog(QDialog): audio_format_map = {0: "best", 1: "aac", 2: "mp3", 3: "flac", 4: "wav", 5: "opus", 6: "m4a", 7: "vorbis"} return audio_format_map.get(self.audio_format_combo.currentIndex(), "best") + def get_audio_normalization_enabled(self) -> bool: + """Returns whether audio normalization is enabled.""" + return self.audio_normalization_checkbox.isChecked() + def get_filename_format(self) -> str: """Returns the filename format string.""" return self.filename_format_input.text().strip() @@ -438,8 +474,10 @@ class DownloadSettingsDialog(QDialog): # Save audio format settings force_audio_format = self.get_force_audio_format_enabled() preferred_audio_format = self.get_preferred_audio_format() + audio_normalization = self.get_audio_normalization_enabled() ConfigManager.set("force_audio_format", force_audio_format) ConfigManager.set("preferred_audio_format", preferred_audio_format) + ConfigManager.set("audio_normalization", audio_normalization) # Save filename format filename_format = self.get_filename_format() diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 23d80cd..fd498f3 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -257,6 +257,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.preferred_output_format = ConfigManager.get("preferred_output_format") or "mp4" self.force_audio_format = ConfigManager.get("force_audio_format") or False self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best" + self.audio_normalization = ConfigManager.get("audio_normalization") or False self.generic_mode_enabled = ConfigManager.get("generic_mode") or False # Track if video analysis is completed self.analysis_completed = False @@ -640,12 +641,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Update Audio Format Settings new_force_audio_format = dialog.get_force_audio_format_enabled() new_preferred_audio_format = dialog.get_preferred_audio_format() + new_audio_normalization = dialog.get_audio_normalization_enabled() audio_format_changed = False - if new_force_audio_format != self.force_audio_format or new_preferred_audio_format != self.preferred_audio_format: + if (new_force_audio_format != self.force_audio_format or + new_preferred_audio_format != self.preferred_audio_format or + new_audio_normalization != self.audio_normalization): self.force_audio_format = new_force_audio_format self.preferred_audio_format = new_preferred_audio_format + self.audio_normalization = new_audio_normalization audio_format_changed = True - logger.info(f"Audio format settings updated - Force: {self.force_audio_format}, Preferred: {self.preferred_audio_format}") + logger.info(f"Audio format settings updated - Force: {self.force_audio_format}, Preferred: {self.preferred_audio_format}, Norm: {self.audio_normalization}") # Update Generic Mode Setting new_generic_mode = dialog.get_generic_mode_enabled() @@ -778,6 +783,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): preferred_output_format=self.preferred_output_format, # Pass preferred format force_audio_format=self.force_audio_format, # Pass force audio format setting preferred_audio_format=self.preferred_audio_format, # Pass preferred audio format + audio_normalization=self.audio_normalization, # Pass audio normalization setting filename_format=filename_format, # Pass the filename format ) diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 4cb0824..ce6d3f0 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -327,6 +327,8 @@ "format_mkv": "MKV (Feature-rich)", "audio_format_settings": "Audio Format Settings", "force_audio_format": "Force audio format for audio-only downloads", + "audio_normalization": "Audio Normalization (EBU R128)", + "audio_normalization_help": "When enabled, audio tracks will be normalized. Note: This requires re-encoding, so a specific audio format (like MP3 or M4A) must be forced.", "filename_format": "Output Filename Format", "filename_format_help": "Available variables: %(title)s, %(uploader)s, %(upload_date)s, %(resolution)s, %(id)s, %(ext)s. Standard yt-dlp output template syntax is supported.", "preferred_audio_format": "Preferred audio format:", diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index 0692e6a..d1f27da 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -93,6 +93,7 @@ class ConfigManager: "preferred_output_format": "mp4", "force_audio_format": False, "preferred_audio_format": "best", + "audio_normalization": False, "filename_format": "%(title)s_%(resolution)s.%(ext)s", "cached_versions": { "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},