From 146fcb3c1f462a7468b83a4312a587ef76bc29f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 11:39:54 +0300 Subject: [PATCH] Restyle using black, improve m4b process handling --- CHANGELOG.md | 4 +- abogen/conversion.py | 75 +++++++++++++++++++++++++++++-------- abogen/gui.py | 27 +++++++++---- abogen/utils.py | 1 + abogen/voice_formula_gui.py | 34 +++++++++++------ 5 files changed, 104 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b567176..e2d5079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1 @@ -- Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10. -- Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12) -- Improvements in documentation and code. \ No newline at end of file +- Fixed m4b chapter generation opens CMD window in Windows. \ No newline at end of file diff --git a/abogen/conversion.py b/abogen/conversion.py index 197c7ed..0be2f26 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -13,6 +13,10 @@ from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from voice_formulas import get_new_voice import static_ffmpeg import threading # for efficient waiting +import subprocess +import sys +import platform + def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) @@ -610,13 +614,16 @@ class ConversionThread(QThread): # If there is only one chapter, skip adding chapters and just return the wav file path if not chapters_time or len(chapters_time) <= 1: self.log_updated.emit( - ("File contains only one chapter or no chapters were detected. The audio will be saved as a standard .wav file instead.\n", "red") + ( + "File contains only one chapter or no chapters were detected. The audio will be saved as a standard .wav file instead.\n", + "red", + ) ) return out_path # generate chapters.txt in the same folder as the output file chapters_info_path = os.path.splitext(out_path)[0] + "_chapters.txt" with open(chapters_info_path, "w", encoding="utf-8") as f: - f.write(';FFMETADATA1\n') # required header for ffmpeg + f.write(";FFMETADATA1\n") # required header for ffmpeg for chapter in chapters_time: f.write(f"[CHAPTER]\n") f.write(f"TIMEBASE=1/10\n") @@ -627,29 +634,67 @@ class ConversionThread(QThread): # call ffmpeg to merge the chapters into the output file # ffmpeg installed on first call to add_paths() static_ffmpeg.add_paths() - import subprocess out_path_m4b = os.path.splitext(out_path)[0] + ".m4b" # ffmpeg -i input.m4b -i ch.txt -map 0:a -map_chapters 1 output.m4b ffmpeg_cmd = [ "ffmpeg", - "-i", out_path, - "-i", chapters_info_path, - "-map", "0:a", - "-map_chapters", "1", - out_path_m4b + "-i", + out_path, + "-i", + chapters_info_path, + "-map", + "0:a", + "-map_chapters", + "1", + out_path_m4b, ] - + self.log_updated.emit("Adding chapters to the audio file...\n") - result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) + + # Define kwargs for Popen + default_encoding = sys.getdefaultencoding() # Get default encoding + kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, # Capture stderr separately + "universal_newlines": True, + "encoding": default_encoding, + "errors": "replace", + } + + # Add Windows-specific settings + if platform.system() == "Windows": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + kwargs.update( + { + "startupinfo": startupinfo, + "creationflags": subprocess.CREATE_NO_WINDOW, + } + ) + + # Use Popen instead of run + process = subprocess.Popen(ffmpeg_cmd, **kwargs) + stdout, stderr = process.communicate() # Get stdout and stderr + # Check for errors in the ffmpeg command - if result.returncode != 0: - self.log_updated.emit((f"FFmpeg error: {result.stderr}", "red")) + if process.returncode != 0: + self.log_updated.emit((f"FFmpeg error (stderr):\n{stderr}", "red")) + if stdout: # Log stdout as well if it exists + self.log_updated.emit((f"FFmpeg output (stdout):\n{stdout}", "orange")) # Log error but continue with original file - self.log_updated.emit(("Falling back to the original audio file without chapters\n", "orange")) + self.log_updated.emit( + ("Falling back to the original audio file without chapters\n", "orange") + ) + # Clean up chapters file even on error + if os.path.exists(chapters_info_path): + os.remove(chapters_info_path) return out_path else: - self.log_updated.emit(("Successfully added chapters to the audio file.\n", "green")) - + self.log_updated.emit( + ("Successfully added chapters to the audio file.\n", "green") + ) + # delete the chapters path and original (wav) file os.remove(chapters_info_path) os.remove(out_path) diff --git a/abogen/gui.py b/abogen/gui.py index b5780db..6bbc607 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -670,9 +670,16 @@ class abogen(QWidget): format_layout = QVBoxLayout() format_layout.addWidget(QLabel("Output Format:", self)) self.format_combo = QComboBox(self) - self.format_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }") + self.format_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) # Add items with display labels and underlying keys - for key, label in [("wav","wav"),("flac","flac"),("mp3","mp3"),("m4b","m4b (with chapters)")]: + for key, label in [ + ("wav", "wav"), + ("flac", "flac"), + ("mp3", "mp3"), + ("m4b", "m4b (with chapters)"), + ]: self.format_combo.addItem(label, key) # Initialize selection by matching saved key idx = self.format_combo.findData(self.selected_format) @@ -1422,6 +1429,7 @@ class abogen(QWidget): if self.preview_playing: try: import pygame + pygame.mixer.music.stop() except Exception: pass @@ -1436,18 +1444,20 @@ class abogen(QWidget): self.voice_combo.setEnabled(False) self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button self.btn_start.setEnabled(False) # Disable start button during preview - + # Start loading animation - ensure signal connection is always active - if hasattr(self, 'loading_movie'): + if hasattr(self, "loading_movie"): # Disconnect previous connections to avoid multiple connections try: self.loading_movie.frameChanged.disconnect() except TypeError: pass # Ignore error if not connected - + # Reconnect the signal self.loading_movie.frameChanged.connect( - lambda: self.btn_preview.setIcon(QIcon(self.loading_movie.currentPixmap())) + lambda: self.btn_preview.setIcon( + QIcon(self.loading_movie.currentPixmap()) + ) ) self.loading_movie.start() @@ -1500,7 +1510,7 @@ class abogen(QWidget): gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) self.preview_thread = VoicePreviewThread( - np_module, kpipeline_class, lang, voice, speed, gpu_ok + np_module, kpipeline_class, lang, voice, speed, gpu_ok ) self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.error.connect(self._preview_error) @@ -1569,7 +1579,7 @@ class abogen(QWidget): self.loading_movie.frameChanged.disconnect() except Exception: pass # Ignore error if not connected - self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setIcon(self.play_icon) self.btn_preview.setToolTip("Preview selected voice") self.btn_preview.setEnabled(True) self.voice_combo.setEnabled(True) @@ -1845,6 +1855,7 @@ class abogen(QWidget): def show_voice_formula_dialog(self): from voice_profiles import load_profiles + profiles = load_profiles() initial_state = None selected_profile = self.selected_profile_name diff --git a/abogen/utils.py b/abogen/utils.py index 1b0625b..1ea9dd8 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -67,6 +67,7 @@ def get_version(): # Define config path def get_user_config_path(): from platformdirs import user_config_dir + # TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used. # On non‑Windows, prefer ~/.config/abogen if it already exists if platform.system() != "Windows": diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 50da22b..2060f0c 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -943,6 +943,7 @@ class VoiceFormulaDialog(QDialog): def new_profile(self): import re + while True: name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") if not ok or not name: @@ -950,8 +951,12 @@ class VoiceFormulaDialog(QDialog): name = name.strip() # Remove leading/trailing spaces if not name: continue - if not re.match(r'^[\w\- ]+$', name): - QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.") + if not re.match(r"^[\w\- ]+$", name): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) continue profiles = load_profiles() # Remove 'New profile' placeholder if not persisted in JSON @@ -1173,13 +1178,16 @@ class VoiceFormulaDialog(QDialog): def rename_profile(self, item): name = item.text().lstrip("*") # block if profile has unsaved changes and it's not a virtual New profile - if self._profile_dirty.get(name, False) and not (self._virtual_new_profile and name == "New profile"): + if self._profile_dirty.get(name, False) and not ( + self._virtual_new_profile and name == "New profile" + ): QMessageBox.warning( self, "Unsaved Changes", "Please save the profile before renaming." ) return old = item.text().lstrip("*") import re + while True: new, ok = QInputDialog.getText( self, "Rename Profile", f"Profile name:", text=old @@ -1189,15 +1197,19 @@ class VoiceFormulaDialog(QDialog): new = new.strip() # Remove leading/trailing spaces if not new: continue - if not re.match(r'^[\w\- ]+$', new): - QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.") + if not re.match(r"^[\w\- ]+$", new): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) continue - + profiles = load_profiles() if new in profiles: QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") continue - + # Special case for renaming the virtual "New profile" if self._virtual_new_profile and name == "New profile": # Create the profile with the new name @@ -1206,12 +1218,12 @@ class VoiceFormulaDialog(QDialog): "language": self.language_combo.currentData(), } save_profiles(profiles) - + # Update tracking properties self._virtual_new_profile = False self._profile_dirty.pop("New profile", None) self._profile_dirty[new] = False - + # Update the current profile name self.current_profile = new item.setText(new) @@ -1220,11 +1232,11 @@ class VoiceFormulaDialog(QDialog): profiles[new] = profiles.pop(old) save_profiles(profiles) item.setText(new) - + # Update the current profile name if it was renamed if self.current_profile == old: self.current_profile = new - + parent = self.parent() if hasattr(parent, "populate_profiles_in_voice_combo"): parent.populate_profiles_in_voice_combo()