Add audio normalization option and support
Introduce an "audio_normalization" option across the app and implement normalization for audio-only downloads. Updates include: add config default (ConfigManager), new language strings, settings UI checkbox + help text and logic to auto-enable force-audio-format when normalization is enabled, and persist the setting. Pass the setting from main app to DownloadThread, and in the downloader force re-encoding to mp3 when necessary and append --postprocessor-args ExtractAudio:-af loudnorm=... to apply EBU R128 normalization. This ensures normalization works reliably (avoids ffmpeg stream-copy errors) and keeps the behavior scoped to ExtractAudio postprocessor.
This commit is contained in:
@@ -72,6 +72,7 @@ class DownloadThread(QThread):
|
|||||||
preferred_output_format="mp4",
|
preferred_output_format="mp4",
|
||||||
force_audio_format=False,
|
force_audio_format=False,
|
||||||
preferred_audio_format="best",
|
preferred_audio_format="best",
|
||||||
|
audio_normalization=False,
|
||||||
filename_format=None,
|
filename_format=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -100,6 +101,7 @@ class DownloadThread(QThread):
|
|||||||
self.preferred_output_format = preferred_output_format
|
self.preferred_output_format = preferred_output_format
|
||||||
self.force_audio_format = force_audio_format
|
self.force_audio_format = force_audio_format
|
||||||
self.preferred_audio_format = preferred_audio_format
|
self.preferred_audio_format = preferred_audio_format
|
||||||
|
self.audio_normalization = audio_normalization
|
||||||
self.filename_format = filename_format
|
self.filename_format = filename_format
|
||||||
self.paused: bool = False
|
self.paused: bool = False
|
||||||
self.cancelled: bool = False
|
self.cancelled: bool = False
|
||||||
@@ -272,6 +274,22 @@ class DownloadThread(QThread):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Using --extract-audio with best quality (no conversion) for audio-only download")
|
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
|
# Output template with resolution in filename
|
||||||
# Use string concatenation instead of Path.joinpath to avoid Path object issues
|
# Use string concatenation instead of Path.joinpath to avoid Path object issues
|
||||||
base_path: str = self.path.as_posix()
|
base_path: str = self.path.as_posix()
|
||||||
|
|||||||
@@ -261,12 +261,24 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
# Load current audio format settings from ConfigManager
|
# Load current audio format settings from ConfigManager
|
||||||
self.force_audio_format_enabled = ConfigManager.get("force_audio_format") or False
|
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.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
|
# Enable/Disable force audio format checkbox
|
||||||
self.force_audio_format_checkbox = QCheckBox(_("settings.force_audio_format"))
|
self.force_audio_format_checkbox = QCheckBox(_("settings.force_audio_format"))
|
||||||
self.force_audio_format_checkbox.setChecked(self.force_audio_format_enabled)
|
self.force_audio_format_checkbox.setChecked(self.force_audio_format_enabled)
|
||||||
audio_format_layout.addWidget(self.force_audio_format_checkbox)
|
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 selection layout
|
||||||
audio_format_select_layout = QHBoxLayout()
|
audio_format_select_layout = QHBoxLayout()
|
||||||
audio_format_label = QLabel(_("settings.preferred_audio_format"))
|
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_help_label.setStyleSheet("color: #cccccc; margin: 5px; font-size: 11px;")
|
||||||
audio_format_layout.addWidget(audio_help_label)
|
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)
|
audio_format_group_box.setLayout(audio_format_layout)
|
||||||
layout.addWidget(audio_format_group_box)
|
layout.addWidget(audio_format_group_box)
|
||||||
|
|
||||||
@@ -337,6 +353,22 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
layout.addWidget(button_box)
|
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:
|
def browse_new_path(self) -> None:
|
||||||
new_path = QFileDialog.getExistingDirectory(self, _("dialogs.select_folder"), str(self.current_path))
|
new_path = QFileDialog.getExistingDirectory(self, _("dialogs.select_folder"), str(self.current_path))
|
||||||
if new_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"}
|
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")
|
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:
|
def get_filename_format(self) -> str:
|
||||||
"""Returns the filename format string."""
|
"""Returns the filename format string."""
|
||||||
return self.filename_format_input.text().strip()
|
return self.filename_format_input.text().strip()
|
||||||
@@ -438,8 +474,10 @@ class DownloadSettingsDialog(QDialog):
|
|||||||
# Save audio format settings
|
# Save audio format settings
|
||||||
force_audio_format = self.get_force_audio_format_enabled()
|
force_audio_format = self.get_force_audio_format_enabled()
|
||||||
preferred_audio_format = self.get_preferred_audio_format()
|
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("force_audio_format", force_audio_format)
|
||||||
ConfigManager.set("preferred_audio_format", preferred_audio_format)
|
ConfigManager.set("preferred_audio_format", preferred_audio_format)
|
||||||
|
ConfigManager.set("audio_normalization", audio_normalization)
|
||||||
|
|
||||||
# Save filename format
|
# Save filename format
|
||||||
filename_format = self.get_filename_format()
|
filename_format = self.get_filename_format()
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
self.preferred_output_format = ConfigManager.get("preferred_output_format") or "mp4"
|
self.preferred_output_format = ConfigManager.get("preferred_output_format") or "mp4"
|
||||||
self.force_audio_format = ConfigManager.get("force_audio_format") or False
|
self.force_audio_format = ConfigManager.get("force_audio_format") or False
|
||||||
self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best"
|
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
|
self.generic_mode_enabled = ConfigManager.get("generic_mode") or False
|
||||||
# Track if video analysis is completed
|
# Track if video analysis is completed
|
||||||
self.analysis_completed = False
|
self.analysis_completed = False
|
||||||
@@ -640,12 +641,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
# Update Audio Format Settings
|
# Update Audio Format Settings
|
||||||
new_force_audio_format = dialog.get_force_audio_format_enabled()
|
new_force_audio_format = dialog.get_force_audio_format_enabled()
|
||||||
new_preferred_audio_format = dialog.get_preferred_audio_format()
|
new_preferred_audio_format = dialog.get_preferred_audio_format()
|
||||||
|
new_audio_normalization = dialog.get_audio_normalization_enabled()
|
||||||
audio_format_changed = False
|
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.force_audio_format = new_force_audio_format
|
||||||
self.preferred_audio_format = new_preferred_audio_format
|
self.preferred_audio_format = new_preferred_audio_format
|
||||||
|
self.audio_normalization = new_audio_normalization
|
||||||
audio_format_changed = True
|
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
|
# Update Generic Mode Setting
|
||||||
new_generic_mode = dialog.get_generic_mode_enabled()
|
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
|
preferred_output_format=self.preferred_output_format, # Pass preferred format
|
||||||
force_audio_format=self.force_audio_format, # Pass force audio format setting
|
force_audio_format=self.force_audio_format, # Pass force audio format setting
|
||||||
preferred_audio_format=self.preferred_audio_format, # Pass preferred audio format
|
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
|
filename_format=filename_format, # Pass the filename format
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -327,6 +327,8 @@
|
|||||||
"format_mkv": "MKV (Feature-rich)",
|
"format_mkv": "MKV (Feature-rich)",
|
||||||
"audio_format_settings": "Audio Format Settings",
|
"audio_format_settings": "Audio Format Settings",
|
||||||
"force_audio_format": "Force audio format for audio-only downloads",
|
"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": "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.",
|
"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:",
|
"preferred_audio_format": "Preferred audio format:",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class ConfigManager:
|
|||||||
"preferred_output_format": "mp4",
|
"preferred_output_format": "mp4",
|
||||||
"force_audio_format": False,
|
"force_audio_format": False,
|
||||||
"preferred_audio_format": "best",
|
"preferred_audio_format": "best",
|
||||||
|
"audio_normalization": False,
|
||||||
"filename_format": "%(title)s_%(resolution)s.%(ext)s",
|
"filename_format": "%(title)s_%(resolution)s.%(ext)s",
|
||||||
"cached_versions": {
|
"cached_versions": {
|
||||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
|
|||||||
Reference in New Issue
Block a user